Compare commits

..
Author SHA1 Message Date
whyour 41910b5236 fix(dependencies): avoid repeated checks when opening dependency management (#3122)
* fix(dependencies): cache status reads and refresh asynchronously

* fix(cli): expose asynchronous dependency refresh operations
2026-11-03 23:08:16 +08:00
whyour 442ab2c616 fix(security): restrict task environment controls and app scopes (#3121)
* fix(security): restrict task environment controls and app scopes

* fix(security): preserve legacy scopes and add read-only access

* feat(security): add read-only scopes for remaining resources

* fix(security): reject apps scope grants in app API

* fix(security): protect system app and task file boundaries

* fix: block case-variant secret paths and task runner env overrides
2026-11-03 23:04:43 +08:00
whyour a5f22f8d44 fix(cli): support cross-platform config and prepare 0.1.4 (#3119) 2026-10-31 02:14:44 +08:00
whyour f2c2f5e4f8 release: Qinglong 2.24.0 2026-10-29 09:58:40 +08:00
whyour 2efc5e4503 build: unify Node 24 and upgrade release flow 2026-10-26 23:26:40 +08:00
whyour b92621bdb8 build: upgrade sqlite3 to 1.1.4 (#3108) 2026-10-25 01:52:58 +08:00
da9c6a6bbb fix: skip new scheduled triggers while a single-instance task is active (#3101)
* fix: skip active single-instance scheduled executions

* fix: recover stale single-instance scheduled tasks

* test: remove obsolete single-instance replacement assertions

---------

Co-authored-by: Patrick Gu <username@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-24 23:11:36 +08:00
whyour d895fbba36 build: use one bundle validation path for all architectures (#3106) 2026-10-24 19:59:41 +08:00
whyour 6862ad4f6c build: use sqlite3 1.1.3 ARM musl prebuilds (#3105) 2026-10-24 17:40:47 +08:00
whyour b8110c52eb fix: isolate Alpine Node runtime and bound URL probes (#3104)
Pin Alpine amd64 to the verified upstream Node runtime without retaining temporary binaries in image layers. Bound fresh-process URL probes and verify serialized amd64/ARMv6 layers in CI. Keep existing Sequelize, SQLite and Debian runtime behavior unchanged.
2026-10-23 23:34:24 +08:00
whyour 99a4d0eff8 fix: batch notification audits and bump CLI to 0.1.3 2026-10-22 01:25:29 +08:00
whyour b486989399 fix: validate ARMv6 upgrades and publish rolling development bundles 2026-10-21 01:43:44 +08:00
whyour 77fe551425 fix: simplify npm packaging and align frontend Node builds 2026-10-19 18:06:06 +08:00
whyour 2669f251ac fix: preserve npm environment during package replacement 2026-10-19 15:41:42 +08:00
whyour 3f02b04dff fix: ship verified npm panel packages and test production migrations 2026-10-19 14:17:53 +08:00
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

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

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

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

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

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

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

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

Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
2026-10-17 17:14:15 +08:00
whyour a1104ef47c fix: report page load timing after load handlers complete 2026-10-17 09:03:50 +08:00
whyour 1a152575cc fix: preserve active system log files when clearing logs 2026-10-17 07:42:10 +08:00
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00
whyour 8305825ffd Merge Python dependency and Shell regression fixes
fix: Python dependency status, GNU awk compatibility and dev regressions
2026-10-15 23:41:16 +08:00
whyour e555fcbb0b fix: honor Python requirement specifiers and portable script discovery 2026-10-15 23:36:21 +08:00
whyour 83e8f6bccd Merge tested dev regression fixes for integration review 2026-10-15 23:28:56 +08:00
whyour 5801f735bc fix: improve shell compatibility and process cleanup
Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail.

Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage.
2026-10-15 23:20:15 +08:00
whyour 53e1129a12 fix: preserve Python source state and repair dev regressions 2026-10-15 09:53:22 +08:00
whyour 13c0a4d21b fix: handle mobile INI editing and silence timeout cleanup
Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup.

Refs #3095, #3096
2026-10-15 01:17:43 +08:00
whyour 126fe8eb67 feat(notify): audit system and JavaScript/Python script notifications 2026-10-14 10:28:43 +08:00
whyour 73c41eed22 fix(i18n): retain Python dependency and notification audit translations 2026-10-14 10:25:13 +08:00
whyour 40149b6f17 feat(deps): support Python Git URLs and requirements files 2026-10-14 10:21:19 +08:00
whyour 6532775a25 feat(python): support isolated venv environments for native deployment 2026-10-14 10:19:50 +08:00
whyour 6469792546 chore(deps): update moment and brace-expansion security patches 2026-10-14 10:18:16 +08:00
whyour 0aa521a288 fix(python): integrate venv startup with source dependency support 2026-10-13 02:24:12 +08:00
whyour 1ac04a8062 fix(python): keep source dependency installs and removals in selected venv 2026-10-13 02:21:10 +08:00
whyour e435f6e886 fix(notify): audit system and JS/Python script notification results 2026-10-13 02:20:59 +08:00
whyour db88d037db fix(python): integrate latest develop and support older venv pip 2026-10-13 02:20:59 +08:00
whyour a0c09046c6 fix(deps): keep security updates within compatible dependency lines 2026-10-13 02:17:55 +08:00
dependabot[bot] 5a9e53c771 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [moment](https://github.com/moment/moment) and [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `moment` from 2.30.1 to 2.31.0
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: moment
  dependency-version: 2.31.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 17:28:13 +00:00
whyour cf236f8324 fix(dependencies): require existing sources and translate install hints 2026-10-12 01:13:53 +08:00
whyour 7334566d3c fix(notification): align audit route and WPUSH fixtures 2026-10-12 01:13:53 +08:00
whyour 4cd6c6ad2e fix(dependencies): quote and validate Python source installs 2026-10-12 00:23:48 +08:00
whyour 81a60254a2 fix(python): share safe startup and honor explicit virtual environments 2026-10-12 00:23:20 +08:00
whyour 1503f07c75 fix(notification): bound audit logs without breaking delivery 2026-10-12 00:23:20 +08:00
jiandanc 0af546cd1d feat: 支持 Python venv 隔离环境,适配 Node.js 原生部署
## 背景
原方案使用系统 Python + pip --prefix 安装依赖到 data/dep_cache/python3,
系统 Python 环境被污染,且无法隔离不同项目的依赖。

## 改动内容

### 核心:Python venv 支持
- shell/start.sh: 使用 python3 -m venv 创建虚拟环境,替代 --prefix 模式
  - 从 .env 文件读取 PYTHON_VENV_DIR 配置
  - 首次启动自动创建 .venv,已存在则跳过
  - 仅将 .venv/bin 加入 PATH 优先级,不设置 PYTHONHOME/PYTHONPATH(避免破坏 venv 机制)
  - .env 不再强制覆盖,仅首次从 .env.example 复制
  - 启动时自动修复 task/ql 软链接指向当前部署目录

### 后端适配
- back/config/const.ts: 新增 PYTHON_VENV_DIR 常量
- back/config/util.ts: venv 模式下跳过 pip --prefix,直接使用 venv 的 pip3

### 开发模式支持
- shell/dev-env.sh: pnpm start 时自动 source,将 .venv/bin 加入 PATH
- package.json: start:back 加入 source dev-env.sh

### 新增文件
- shell/start-simplify.sh: 精简版启动脚本(跳过系统依赖安装,适用于已预装环境的服务器)
- README-NODE.md: Node.js 原生部署完整文档

### 配置
- .env.example: 新增 PYTHON_VENV_DIR=./.venv 配置项(默认注释状态)

## 兼容性
- Docker 模式不受影响(使用独立的 docker-entrypoint.sh,走原有 --prefix 逻辑)
- 未配置 PYTHON_VENV_DIR 时默认使用系统 Python(向后兼容)
2026-06-07 21:03:51 +08:00
copilot-swe-agent[bot]andwhyour 0d33d2321a fix: address code review feedback - improve notifyType logic and typing
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:50:47 +00:00
copilot-swe-agent[bot]andwhyour 79964f149c feat: add notification audit log feature
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:49:18 +00:00
copilot-swe-agent[bot] bcb2471768 Initial plan 2026-04-25 06:44:54 +00:00
copilot-swe-agent[bot]andwhyour fcf0ee619b Improve URL pattern matching and remove redundant checks
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:51:12 +00:00
copilot-swe-agent[bot]andwhyour f3ec352066 Address code review feedback: improve pattern matching and error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:49:41 +00:00
copilot-swe-agent[bot]andwhyour 023d0f1cc4 Fix pyproject.toml path handling and add error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:46:12 +00:00
copilot-swe-agent[bot]andwhyour f526d3f972 Add support for GitHub URLs and requirements files in Python dependencies
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:41:43 +00:00
copilot-swe-agent[bot] e28f746294 Initial plan 2025-12-27 08:35:03 +00:00
285 changed files with 28362 additions and 45012 deletions
+3
View File
@@ -21,3 +21,6 @@ JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
+203 -252
View File
@@ -8,6 +8,10 @@ on:
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/upgradePrestart.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "cli/src/internal/maintenance/nodeRuntime.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
@@ -33,15 +37,19 @@ 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
@@ -62,17 +70,17 @@ jobs:
build-static:
if: ${{ !inputs.static_sync_only }}
needs: [validate, runtime-tools]
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "10.34.6"
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "22"
node-version-file: ".nvmrc"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -81,12 +89,10 @@ jobs:
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
pnpm build:info
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: static/
- run: pnpm build:info
- name: Pack the npm panel archive from the verified build
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
@@ -95,20 +101,31 @@ jobs:
if-no-files-found: error
include-hidden-files: true
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
retention-days: 14
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
: "${STATIC_TOKEN:?Missing static repository token}"
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
npm-package:
if: ${{ !inputs.static_sync_only }}
needs: build-static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: qinglong-static-${{ github.sha }}
- name: publish verified upgrade download sizes
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
: "${STATIC_TOKEN:?Missing static repository token}"
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab:
needs: build-static
@@ -213,6 +230,8 @@ jobs:
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -225,10 +244,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "10.34.6"
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -278,6 +297,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -299,6 +319,8 @@ jobs:
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -311,10 +333,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "10.34.6"
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -363,6 +385,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -371,7 +394,7 @@ jobs:
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
@@ -382,263 +405,191 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
verify-npm-package:
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, npm-package]
needs: [build-alpine, build-debian, verify-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:
- uses: actions/checkout@v6
- name: Set up Node for trusted publishing
uses: actions/setup-node@v6
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
node-version: '24'
registry-url: https://registry.npmjs.org
package-manager-cache: false
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v6
with:
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
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
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 { 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
shopt -s nullglob
archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
node "$npm_cli" publish "$QINGLONG_PUBLISH_TARBALL" --ignore-scripts --access public --registry=https://registry.npmjs.org
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
# Both channels publish packages from this run's immutable image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: [build-alpine, build-debian]
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
if: ${{ matrix.platform != 'linux/arm64' }}
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+20 -2
View File
@@ -25,7 +25,7 @@ on:
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
type: string
default: ''
@@ -93,8 +93,26 @@ jobs:
if-no-files-found: error
retention-days: 14
platform-smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run build:cli
- run: node --test cli/test/remote/platformSmoke.test.cjs
publish:
needs: package
needs: [package, platform-smoke]
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
-312
View File
@@ -1,312 +0,0 @@
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.'
+58 -81
View File
@@ -6,6 +6,11 @@ on:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'docker/Dockerfile*'
- 'shell/node-runtime-probe.cjs'
- 'scripts/verify-panel-package.cjs'
- 'scripts/verify-image-node-layers.py'
- 'test/back/node-runtime-probe.test.cjs'
workflow_dispatch:
permissions:
@@ -19,11 +24,7 @@ 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:
@@ -32,61 +33,29 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '22'
node-version: '20'
cache: pnpm
- run: pnpm install --frozen-lockfile
- 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
- run: pnpm build:front && pnpm build:back && pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: release-readiness-static
path: static/
include-hidden-files: true
if-no-files-found: error
sqlite-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Pack the verified npm archive for isolated runtime validation
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
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
name: release-readiness-npm-panel
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
images:
needs: [regression, static, sqlite-install]
needs: [regression, static]
runs-on: ubuntu-22.04
timeout-minutes: 60
strategy:
@@ -100,15 +69,7 @@ jobs:
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
@@ -163,32 +124,48 @@ jobs:
run: |
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
--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 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'
-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:
name: release-readiness-debian-image
path: |
release-readiness-debian-image.tar.gz
release-readiness-debian-image.sha256
release-readiness-debian-image.json
context: .
file: ${{ matrix.file }}
platforms: linux/arm/v6
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}-armv6
load: true
push: false
provenance: false
sbom: false
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
if: startsWith(matrix.image, 'alpine')
run: |
docker run --rm --network none --platform linux/arm/v6 \
-e QEMU_CPU=arm1176 --entrypoint node \
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
- name: Check Node binaries in every serialized image layer
if: startsWith(matrix.image, 'alpine')
run: |
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
- name: Export native image for isolated dev validation
if: matrix.image == 'debian' || matrix.image == 'alpine'
run: |
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian' || matrix.image == 'alpine'
with:
name: release-readiness-${{ matrix.image }}-image
path: release-readiness-${{ matrix.image }}-image.*
compression-level: 0
retention-days: 3
if-no-files-found: error
-40
View File
@@ -1,40 +0,0 @@
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
+3 -8
View File
@@ -11,26 +11,21 @@ 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: '10.34.6'
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
node-version: '20'
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-node-${{ matrix.node }}
name: execution-baseline
path: execution-baseline.json
+10
View File
@@ -29,6 +29,7 @@ __pycache__
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/
.claude/
@@ -38,3 +39,12 @@ __pycache__
# Local security audit artifacts
/audit/
# Local browser tests, screenshots, reports, and temporary test output
/.playwright-cli/
/output/
/playwright-report/
/test-results/
/blob-report/
/coverage/
/.nyc_output/
-22
View File
@@ -1,22 +0,0 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+1 -2
View File
@@ -1,2 +1 @@
strict-peer-dependencies=false
strict-dep-builds=true
strict-peer-dependencies=false
+1
View File
@@ -0,0 +1 @@
22
+10
View File
@@ -0,0 +1,10 @@
# 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 中重新安装。
+4 -10
View File
@@ -62,14 +62,10 @@ docker run -d \
### npm
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}}
```
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
```bash
npm i @whyour/qinglong@2.23.0
npm i @whyour/qinglong
```
## Deployment
@@ -109,15 +105,13 @@ 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@10.34.6
pnpm install --frozen-lockfile
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
+4 -10
View File
@@ -64,14 +64,10 @@ docker run -d \
### npm
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}}
```
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
```bash
npm i @whyour/qinglong@2.23.0
npm i @whyour/qinglong
```
## 部署
@@ -111,15 +107,13 @@ 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@10.34.6
pnpm install --frozen-lockfile
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import {
diagnoseClientIp,
getTrustProxyConfig,
@@ -36,7 +37,7 @@ export default (app: Router) => {
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
message: error instanceof Error ? error.message : t('配置更新失败'),
});
}
},
+68 -6
View File
@@ -11,12 +11,11 @@ export default (app: Router) => {
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -31,6 +30,69 @@ export default (app: Router) => {
},
);
route.post(
'/refresh',
celebrate({
body: Joi.object({
type: Joi.string().valid('nodejs', 'python3').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const { job, accepted } = dependenceService.startRefresh(req.body.type);
if (!accepted) {
return res.status(409).send({
code: 409,
message: 'Another dependency refresh is already running',
});
}
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/active',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
res.set('Cache-Control', 'no-store');
return res.send({
code: 200,
data: dependenceService.getActiveRefreshJob() || null,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/:id',
celebrate({
params: Joi.object({ id: Joi.string().uuid().required() }),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const job = dependenceService.getRefreshJob(req.params.id);
if (!job) {
return res.status(404).send({
code: 404,
message: 'Dependency refresh job not found',
});
}
res.set('Cache-Control', 'no-store');
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
+2 -2
View File
@@ -27,7 +27,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow('').disallow('system'),
scopes: Joi.array().items(Joi.string().required()),
scopes: Joi.array().items(Joi.string().required().invalid('apps')),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -47,7 +47,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
scopes: Joi.array().items(Joi.string().invalid('apps')),
id: Joi.number().required(),
}),
}),
+24
View File
@@ -205,6 +205,17 @@ export default (app: Router) => {
},
);
route.get(
'/update-status',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send(await Container.get(SystemService).getUpgradeStatus());
} catch (e) {
return next(e);
}
},
);
route.put(
'/reload',
celebrate({
@@ -383,6 +394,19 @@ export default (app: Router) => {
},
);
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
+13 -28
View File
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { errStack } from './shared/errors';
import { initializeLanguageSync } from './shared/languageSync';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
@@ -27,6 +28,7 @@ class Application {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
await initializeLanguageSync();
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
@@ -39,11 +41,18 @@ class Application {
}
private startMasterProcess() {
const readyTimeout =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number }).arm_version,
) === 6
? 120000
: 30000;
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
this.waitForWorkerReady(grpcWorker, readyTimeout)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
@@ -69,7 +78,7 @@ class Application {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
@@ -146,32 +155,8 @@ class Application {
}
private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
const { createHttpApplication } = await import('./loaders/httpApplication');
return createHttpApplication();
}
private setupMasterShutdown() {
+139
View File
@@ -0,0 +1,139 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
// The first scheduler connection can outlive its short RPC deadline. Let the
// normal single-flight recovery finish within the supervised startup budget.
async function waitForUpgradeScheduler(
readiness: { recover(): Promise<boolean> },
timeoutMs: number,
retryMs = 250,
) {
const deadline = Date.now() + timeoutMs;
do {
if (await readiness.recover()) return;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryMs, remaining)),
);
} while (Date.now() < deadline);
throw new Error('Upgrade scheduler readiness failed');
}
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { sequelize } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await sequelize.transaction(async (transaction) => {
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
transaction,
});
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
transaction,
});
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
transaction,
});
if ((rows[0] as { value: number })?.value !== 42)
throw new Error('Upgrade database readback failed');
});
inject();
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
await waitForUpgradeScheduler(
cronClient.readiness,
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6
? 60000
: 10000,
);
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await sequelize.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
+1
View File
@@ -27,6 +27,7 @@ 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',
+162 -38
View File
@@ -1,19 +1,25 @@
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, TASK_COMMAND } from './const';
import { PYTHON_INSTALL_DIR, PYTHON_VENV_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';
@@ -671,7 +677,9 @@ 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;
@@ -681,61 +689,177 @@ 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 "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
[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
try:
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}`),
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}`),
};
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.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'),
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
return `${baseCommands[type]} ${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.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
@@ -820,7 +944,7 @@ async function _updateLinuxMirror(
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
+19 -1
View File
@@ -24,7 +24,25 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export type AppScope =
| 'crons'
| 'crons:read'
| 'subscriptions'
| 'subscriptions:read'
| 'envs'
| 'envs:read'
| 'configs'
| 'configs:read'
| 'scripts'
| 'scripts:read'
| 'logs'
| 'logs:read'
| 'dependencies'
| 'dependencies:read'
| 'dashboard'
| 'dashboard:read'
| 'system'
| 'apps';
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
+16
View File
@@ -28,8 +28,12 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
}
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo {
value: string;
timestamp: number;
@@ -88,6 +103,7 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>;
export interface SystemInstance
+39 -3
View File
@@ -6,7 +6,7 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import validationErrors from '../middlewares/validationErrors';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
@@ -112,7 +112,43 @@ export default ({ app }: { app: Application }) => {
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const isReadRequest =
(['GET', 'HEAD'].includes(req.method) &&
!(
key === 'crons' && /^\/open\/crons\/import\/?$/.test(pathLower)
)) ||
(['scripts', 'logs'].includes(key || '') &&
req.method === 'POST' &&
/^\/open\/(?:scripts|logs)\/download\/?$/.test(pathLower));
const hasNamespaceScope =
doc.scopes.includes(key as AppScope) ||
([
'crons',
'subscriptions',
'envs',
'configs',
'scripts',
'logs',
'dependencies',
'dashboard',
].includes(key || '') &&
doc.scopes.includes(`${key}:read` as AppScope) &&
isReadRequest);
if (!hasNamespaceScope) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
// The built-in system app still needs its local CLI maintenance API.
const appRouteAllowed =
key !== 'system' ||
doc.name === 'system' ||
((req.method === 'GET' || req.method === 'HEAD') &&
(pathLower === '/open/system' || pathLower === '/open/system/')) ||
(req.method === 'PUT' && pathLower === '/open/system/notify');
if (!appRouteAllowed) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
@@ -197,7 +233,7 @@ export default ({ app }: { app: Application }) => {
});
});
app.use(errors());
app.use(validationErrors);
app.use(uniqueConstraintError);
app.use(
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+3 -2
View File
@@ -222,6 +222,9 @@ export default async () => {
}
} catch { }
// Replace legacy task environment files before recovered tasks can run.
await envService.set_envs();
// 初始化保存一次ck和定时任务数据
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
@@ -229,8 +232,6 @@ export default async () => {
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
await shareStore.updateAuthInfo(authInfo);
await openService.refreshApps();
+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('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
}
}
};
+18
View File
@@ -0,0 +1,18 @@
import { errors, isCelebrateError } from 'celebrate';
import { ErrorRequestHandler } from 'express';
import { t } from '../shared/i18n';
const respond = errors();
// Schemas are compiled once at startup; resolve their messages per request so
// changing the system language also changes errors from those existing schemas.
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
if (isCelebrateError(error)) {
for (const detail of error.details.values()) {
detail.message = detail.details.map((item) => t(item.message)).join('. ');
}
}
return respond(error, req, res, next);
};
export default validationErrors;
+10
View File
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3;
}
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -271,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+175 -1
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 v3.17.3
// protoc v6.31.1
// source: back/protos/api.proto
/* eslint-disable */
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined;
}
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem {
return {
id: undefined,
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
},
};
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService;
export const ApiService = {
getEnvs: {
@@ -4388,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail",
requestStream: false,
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail(
request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+3 -3
View File
@@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
import { t, tf } from '../shared/i18n';
// Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => {
@@ -67,7 +67,7 @@ const addCron = (
createCronJob(
schedule,
async () => {
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
await runCron(
item.command,
item,
@@ -104,7 +104,7 @@ const addCron = (
scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
id,
jobs.length,
);
+25 -2
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest,
Response,
SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
@@ -238,7 +239,8 @@ export const systemNotify = async (
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
@@ -246,10 +248,31 @@ export const systemNotify = async (
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
const cron =
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...cron,
labels: cron.labels ?? [],
+16 -15
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
@@ -9,24 +10,24 @@ const delCron = (
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(
'[schedule][取消定时任务] 任务ID: %s',
id,
);
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
// 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks
.get(id)
?.filter((x) => x != null)
.forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
+7 -7
View File
@@ -641,7 +641,7 @@ export default class CronService {
stopped.add(pid);
} catch (error) {
this.logger.error(
'[panel][停止任务失败] PID: %s, 错误: %s',
t('[panel][停止任务失败] PID: %s, 错误: %s'),
pid,
asError(error).message
);
@@ -695,7 +695,7 @@ export default class CronService {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
);
}
}
@@ -791,20 +791,20 @@ export default class CronService {
const result = await completed;
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
id,
result.error.message
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
cronId,
asError(error).message
);
@@ -813,7 +813,7 @@ export default class CronService {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
'[panel][关闭任务日志失败] %s',
t('[panel][关闭任务日志失败] %s'),
asError(error).message
);
}
@@ -845,7 +845,7 @@ export default class CronService {
);
} catch (error) {
this.logger.error(
'[panel][清理任务状态失败] %s',
t('[panel][清理任务状态失败] %s'),
asError(error).message
);
}
+136 -34
View File
@@ -20,22 +20,35 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
import { randomUUID } from 'crypto';
type RefreshType = 'nodejs' | 'python3';
type RefreshJob = {
id: string;
type: RefreshType;
state: 'running' | 'completed' | 'failed';
};
@Service()
export default class DependenceService {
private activeRefreshJob?: RefreshJob;
private refreshJobs = new Map<string, RefreshJob>();
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
) { }
) {}
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;
});
@@ -59,6 +72,7 @@ export default class DependenceService {
...other,
status: DependenceStatus.queued,
});
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]);
return newDoc;
@@ -74,21 +88,25 @@ export default class DependenceService {
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed,
(x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
{ where: { id: installedDeps.map((x) => x.id!) } },
);
this.installDependenceOneByOne(docs, false, force);
this.installDependenceOneByOne(installedDeps, false, force);
}
return docs;
}
@@ -111,11 +129,6 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -138,6 +151,52 @@ export default class DependenceService {
}
}
public startRefresh(type: RefreshType): {
job: RefreshJob;
accepted: boolean;
} {
if (this.activeRefreshJob) {
return {
job: this.activeRefreshJob,
accepted: this.activeRefreshJob.type === type,
};
}
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
this.activeRefreshJob = job;
this.refreshJobs.set(job.id, job);
while (this.refreshJobs.size > 20) {
const oldestId = this.refreshJobs.keys().next().value;
this.refreshJobs.delete(oldestId);
}
void this.refreshInstalledStatuses(DependenceTypes[type])
.then(() => {
job.state = 'completed';
})
.catch((error) => {
this.logger.error('Dependency refresh failed: %o', error);
job.state = 'failed';
})
.finally(() => {
if (this.activeRefreshJob === job) {
this.activeRefreshJob = undefined;
}
});
return { job, accepted: true };
}
public getRefreshJob(id: string): RefreshJob | undefined {
return this.refreshJobs.get(id);
}
public getActiveRefreshJob(): RefreshJob | undefined {
return this.activeRefreshJob?.state === 'running'
? this.activeRefreshJob
: undefined;
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -153,20 +212,31 @@ export default class DependenceService {
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checkableDocs = docs.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
);
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
{
where: {
id: doc.id,
status: DependenceStatus.installed,
name: doc.name,
type: doc.type,
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
} as any,
},
);
}
}
@@ -175,7 +245,10 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -296,6 +369,26 @@ 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);
@@ -345,9 +438,7 @@ export default class DependenceService {
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
let command = validatedCommand;
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
@@ -370,7 +461,13 @@ export default class DependenceService {
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
if (isInstall && !force) {
// pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -378,7 +475,7 @@ export default class DependenceService {
}
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (!isPythonDependence && depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -470,15 +567,20 @@ 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,
+22 -3
View File
@@ -14,12 +14,17 @@ import {
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
import {
assertAllowedTaskEnvName,
isAllowedTaskEnvName,
} from '../shared/taskEnvName';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
payloads.forEach((payload) => assertAllowedTaskEnvName(payload.name));
const envs = await this.envs();
let position = initPosition;
if (
@@ -52,6 +57,7 @@ export default class EnvService {
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
assertAllowedTaskEnvName(tab.name);
const newDoc = await this.updateDb(tab);
await this.set_envs();
return newDoc;
@@ -151,7 +157,10 @@ export default class EnvService {
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result;
return result.map((env) => ({
...env,
securityBlocked: !isAllowedTaskEnvName(env.name),
}));
} catch (error) {
throw error;
}
@@ -182,11 +191,14 @@ export default class EnvService {
}
public async enabled(ids: number[]) {
const docs = await EnvModel.findAll({ where: { id: ids } });
docs.forEach((doc) => assertAllowedTaskEnvName(doc.name));
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
assertAllowedTaskEnvName(name);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
@@ -242,6 +254,13 @@ export default class EnvService {
name: { [Op.not]: null },
status: EnvStatus.normal,
});
const invalid = envs.filter((env) => !isAllowedTaskEnvName(env.name));
if (invalid.length > 0) {
this.logger.warn(
'Skipped reserved environment variables in task files: %s',
JSON.stringify(invalid.map((env) => ({ id: env.id, name: env.name }))),
);
}
const groups = groupBy(envs, 'name');
let env_string = '';
let js_env_string = '';
@@ -250,8 +269,8 @@ export default class EnvService {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
// 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
// Ignore invalid and reserved names, including legacy database rows.
if (isAllowedTaskEnvName(key)) {
let value = group
.map((x) => x.value)
.join('&')
+2 -2
View File
@@ -50,13 +50,13 @@ export class GrpcServerService {
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials);
const boundPort = await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
return boundPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
+4 -3
View File
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
import Logger from '../loaders/logger';
import cronClient from '../schedule/client';
import { HttpServerService } from './http';
import { runtimeBuild } from '../shared/runtimeBuild';
interface HealthStatus {
build: typeof runtimeBuild;
status: 'ok' | 'error';
services: {
http: boolean;
@@ -22,12 +24,11 @@ interface HealthStatus {
export class HealthService {
private startTime = Date.now();
constructor(
private httpServerService: HttpServerService,
) {}
constructor(private httpServerService: HttpServerService) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
build: runtimeBuild,
status: 'ok',
services: {
http: true,
+1 -1
View File
@@ -8,6 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
return resolveFileAccess(config.logPath, [filePath || '', fileName], ['.tmp']);
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
@@ -126,7 +127,7 @@ export class MetricsService {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info('性能指标报告:', report);
Logger.info(t('性能指标报告:'), report);
return report;
}
}
+41 -25
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
@@ -55,27 +56,33 @@ export default class NotificationService {
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
let type: string | undefined;
let success = false;
try {
const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) {
this.title = title;
this.content = content;
const { type: _, ...overrides } = notificationInfo || {};
const { type: _saved, ...rest } = saved;
this.params = { ...rest, ...overrides };
try {
success = !!(await this.modeMap.get(type)?.call(this));
} catch (error) {
console.error(error);
}
}
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return false;
}
public async testNotify(
@@ -84,14 +91,23 @@ export default class NotificationService {
content: string,
) {
const { type, ...rest } = info;
if (type) {
if (!type) return true;
let success = false;
try {
this.title = title;
this.content = content;
this.params = rest;
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
success = !!(await this.modeMap.get(type)?.call(this));
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return true;
}
private parseMailRecipients(value?: string) {
@@ -921,7 +937,8 @@ export default class NotificationService {
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
@@ -974,5 +991,4 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
}
+10
View File
@@ -92,6 +92,10 @@ export default class OpenService {
private async updateDb(payload: Partial<App>): Promise<App> {
return this.withAppsLock(async () => {
const target = await AppModel.findOne({ where: { id: payload.id } });
if (target?.name === 'system' || payload.name === 'system') {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
@@ -109,6 +113,12 @@ export default class OpenService {
public async remove(ids: number[]) {
return this.withAppsLock(async () => {
const systemApp = await AppModel.findOne({
where: { id: ids, name: 'system' },
});
if (systemApp) {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
+13 -12
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createCronJob, CronJob } from '../shared/cronScheduler';
@@ -83,7 +84,7 @@ export default class ScheduleService {
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
@@ -103,7 +104,7 @@ export default class ScheduleService {
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
others.id,
result.error.message,
);
@@ -111,7 +112,7 @@ export default class ScheduleService {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
'[panel][任务错误回调失败] %s',
t('[panel][任务错误回调失败] %s'),
asError(error).message,
);
}
@@ -127,12 +128,12 @@ export default class ScheduleService {
} catch (error) {
result.error ??= asError(error);
this.logger.error(
'[panel][任务结束回调失败] %s',
t('[panel][任务结束回调失败] %s'),
asError(error).message,
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
others.id,
result.code,
);
@@ -141,7 +142,7 @@ export default class ScheduleService {
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
@@ -156,7 +157,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
_id,
schedule,
name,
@@ -183,7 +184,7 @@ export default class ScheduleService {
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
_id,
schedule,
asError(error).message,
@@ -204,7 +205,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -219,7 +220,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
_id,
name,
command,
@@ -236,7 +237,7 @@ export default class ScheduleService {
},
(err) => {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
command,
err,
);
@@ -264,7 +265,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
_id,
name,
);
+21 -10
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs/promises';
@@ -61,10 +62,10 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
} catch {}
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
} catch (error) {
this.logger.error('生成私钥文件失败', error);
this.logger.error(t('生成私钥文件失败'), error);
}
}
@@ -73,7 +74,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
this.logger.error(t('删除私钥文件失败'), error);
}
}
@@ -82,9 +83,13 @@ export default class SshKeyService {
host: string,
proxy?: string,
) {
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
if (
!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))
) {
throw Object.assign(new Error('Invalid SSH host or proxy'), {
status: 400,
});
}
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
@@ -92,7 +97,10 @@ export default class SshKeyService {
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock(
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
@@ -108,7 +116,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
}
}
@@ -147,7 +155,7 @@ export default class SshKeyService {
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
}
}
}
@@ -167,7 +175,10 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
+123 -64
View File
@@ -1,10 +1,11 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
import { request } from 'undici';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -32,6 +33,8 @@ import {
SystemInstance,
SystemModel,
SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
@@ -53,7 +56,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) { }
) {}
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -158,7 +161,9 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
`export http_proxy=${shellQuote(
info.dependenceProxy,
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -277,26 +282,24 @@ export default class SystemService {
}
public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const currentVersionContent = await parseVersion(config.versionFile);
let lastVersionContent;
try {
const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{
dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
},
);
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
const { body, statusCode } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{ signal: controller.signal },
);
const text = await body.text();
if (statusCode !== 200) throw new Error('Version request failed');
const lastVersionContent = parseContentVersion(text);
if (
!lastVersionContent ||
typeof lastVersionContent.version !== 'string' ||
!/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
) {
throw new Error('Invalid version response');
}
return {
@@ -314,8 +317,10 @@ export default class SystemService {
} catch (error: any) {
return {
code: 400,
message: error.message,
message: t('检查更新失败,请检查网络后重试'),
};
} finally {
clearTimeout(timeout);
}
}
@@ -339,45 +344,51 @@ export default class SystemService {
}
public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
return this.startUpgradeJob('download');
}
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
private upgradeJobs() {
return require(path.join(
config.rootPath,
'cli/src/internal/maintenance/panelUpgrade.cjs',
));
}
cp.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
public async getUpgradeStatus() {
return {
code: 200,
data: await this.upgradeJobs().getStatus(config.tmpPath),
};
}
cp.on('error', (err) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: JSON.stringify(err),
status: 'failed',
});
});
cp.on('exit', (code) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: '',
status: code === 0 ? 'success' : 'failed',
});
});
return { code: 200 };
private async startUpgradeJob(action: 'download' | 'apply') {
try {
const data = await this.upgradeJobs().startJob(
{
root: config.rootPath,
tmp: config.tmpPath,
env: {
...process.env,
dir_data: config.dataPath,
dir_config: config.configPath,
QlPort: String(config.port),
QlBaseUrl: config.baseUrl,
},
},
action,
);
return { code: 200, data };
} catch (error: any) {
this.logger.error('[upgrade] %s', error.message);
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
}
}
public async reloadSystem(target?: 'system' | 'data') {
if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') };
}
if (target === 'system') return this.startUpgradeJob('apply');
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
@@ -407,11 +418,13 @@ export default class SystemService {
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: t('通知发送成功') };
} else {
@@ -419,16 +432,36 @@ export default class SystemService {
}
}
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
limit: 200,
});
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(
`${logPathPrefix} real_time=true ${command}`,
callback,
{
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
}
public async stop({ command, pid }: { command: string; pid: number }) {
@@ -459,8 +492,20 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
const allowed = new Set([
'db',
'upload',
'config',
'scripts',
'log',
'deps',
'syslog',
'dep_cache',
'raw',
'repo',
'ssh.d',
'script-history',
]);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
@@ -470,9 +515,13 @@ export default class SystemService {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
await promisify(execFile)(
'tar',
['-zcf', config.dataTgzFile, '--', ...dataPaths],
{
cwd: path.dirname(config.dataPath),
},
);
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -563,7 +612,17 @@ export default class SystemService {
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title));
// All workers keep append handles open; unlinking strands future writes
// in a deleted inode until rotation. Preserve the file while clearing it.
const file = await fs.promises.open(
path.join(config.systemLogPath, log.title),
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
);
try {
await file.truncate(0);
} finally {
await file.close();
}
}
}
+2 -2
View File
@@ -173,7 +173,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
@@ -219,7 +219,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
+66 -9
View File
@@ -22,6 +22,7 @@ export interface CronSchedule {
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
@@ -58,17 +59,73 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
// step does not land on 7. node-cron accepts those ranges with different days.
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
normalized[5] = expression.fields.dayOfWeek.values.join(',');
}
// Most rules are already supported; only inspect individual fields on failure.
if (!validate(normalized.join(' '))) {
const fields = [
expression.fields.second,
expression.fields.minute,
expression.fields.hour,
expression.fields.dayOfMonth,
expression.fields.month,
expression.fields.dayOfWeek,
];
normalized = normalized.map((value, index) => {
// Preserve supported lists, ranges and names. Expand only fields node-cron
// cannot consume, such as numeric/name-start steps; never compress them.
const probe = ['0', '0', '0', '*', '*', '*'];
probe[index] = value;
if (validate(probe.join(' '))) return value;
const values = fields[index].values;
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
if (values.length === 8) return '*';
// # applies to a single weekday; normalize its numeric spelling only.
return values.length === 1
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
: value;
}
return values.join(',');
});
}
const months = expression.fields.month.values;
// cron-parser 4 clipped dates outside a single selected month's maximum
// before deciding whether the day field covered the whole month.
const dayCount = expression.fields.dayOfMonth.values.filter(
(day) =>
months.length !== 1 ||
typeof day !== 'number' ||
day <= daysInMonth[months[0] - 1],
).length;
const weekCount = expression.fields.dayOfWeek.values.length;
// Preserve the legacy count-based wildcard rules, including repeated Sunday
// and L tokens: an ignored day field must not remain an AND constraint.
const weekdayPattern = [
...normalized.slice(0, 3),
'*',
...normalized.slice(4),
].join(' ');
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
weekCount === 8
? [[...normalized.slice(0, 5), '*'].join(' ')]
: dayCount < 31
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
: [weekdayPattern];
const valid = patterns.map((pattern) => validate(pattern));
if (patterns.length === 2 && !valid[0] && valid[1]) {
// A date branch can be impossible for all chosen months while the weekday
// branch still runs. Drop it only if its syntax is valid with any month.
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
patterns.shift();
valid.shift();
}
}
if (!valid.every(Boolean)) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from './i18n';
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
@@ -36,7 +37,7 @@ export function createCronJob(
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
options.name,
date.toISOString(),
);
@@ -45,7 +46,7 @@ export function createCronJob(
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
options.name,
error instanceof Error ? error.message : String(error),
);
+4 -1
View File
@@ -24,8 +24,11 @@ export function resolveFileAccess(
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
// File systems such as default APFS can resolve a differently cased spelling
// of the same entry. Check every protected name without case sensitivity.
const blockedNames = new Set(blacklist.map((part) => part.toLowerCase()));
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
relative.split(path.sep).some((part) => blockedNames.has(part.toLowerCase()));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
+225 -92
View File
@@ -1,112 +1,235 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': 'File not accessible',
'文件不存在': 'File not found',
'路径不存在': 'Path does not exist',
'路径不正确': 'Invalid path',
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'[schedule][Stopping running task] Task ID: %s, PID: %s',
'接口已下线,请使用 /configs/detail 接口':
'This endpoint has been retired. Use /configs/detail instead.',
'接口已下线,请使用 /logs/detail 接口':
'This endpoint has been retired. Use /logs/detail instead.',
'接口已下线,请使用 /scripts/detail 接口':
'This endpoint has been retired. Use /scripts/detail instead.',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
请先初始化: 'Please initialize the panel first',
请先关闭两步验证: 'Please disable two-factor authentication first',
'失败次数过多,请稍后重试':
'Too many failed attempts. Please try again later.',
配置更新失败: 'Failed to update configuration',
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
环境变量名称不可使用: 'Environment variable name is reserved or invalid',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
脚本不存在: 'Script does not exist',
请选择脚本文件: 'Please select a script file',
'历史版本读取失败,请检查历史文件':
'Failed to read version history. Check the history file.',
历史版本不存在或已清理:
'This history version does not exist or has been removed',
'文件已变化,请刷新历史版本后重试':
'The file has changed. Refresh version history and try again.',
'历史版本容量不足,无法安全保存':
'Version history storage is full. The file cannot be saved safely.',
'跳过订阅 %s 的无效定时配置: %s':
'Skipping invalid schedule for subscription %s: %s',
'[schedule][准备运行任务] 命令: %s':
'[schedule][Preparing task] Command: %s',
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
'[schedule][取消定时任务] 任务ID: %s':
'[schedule][Cancelling scheduled task] Task ID: %s',
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
'[panel][停止任务失败] PID: %s, 错误: %s':
'[panel][Failed to stop task] PID: %s, error: %s',
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
'[panel][Task failed] Task ID: %s, error: %s',
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
'[panel][Task finished] Task ID: %s, exit code: %j',
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
'[panel][Failed to create task] Task ID: %s, error: %s',
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
'性能指标报告:': 'Performance metrics:',
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
'[panel][任务结束回调失败] %s':
'[panel][Task completion callback failed] %s',
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
'[panel][取消定时任务] 任务名: %s':
'[panel][Cancelling scheduled task] Name: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
'[panel][Task failed] Command: %s, error: %j',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
'[panel][Cancelling interval task] Task ID: %s, name: %s',
生成私钥文件失败: 'Failed to generate private key file',
删除私钥文件失败: 'Failed to delete private key file',
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
'跳过订阅 %s 的无效 SSH 配置: %s':
'Skipping invalid SSH configuration for subscription %s: %s',
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
'[schedule][Running late task] Task: %s, scheduled time: %s',
'[schedule][定时回调失败] 任务: %s, 错误: %s':
'[schedule][Scheduled callback failed] Task: %s, error: %s',
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task queued] Running: %s, waiting: %s',
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Processing task] Running: %s, waiting: %s',
'[schedule][任务处理成功] 参数 %s':
'[schedule][Task succeeded] Arguments: %s',
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task finished] Running: %s, waiting: %s',
'[schedule][任务重复运行] 通知失败 %s':
'[schedule][Duplicate task] Notification failed: %s',
'[schedule][任务重复运行] 参数 %s':
'[schedule][Duplicate task] Arguments: %s',
'[schedule][停止已运行任务] 任务ID: %s':
'[schedule][Stopping running task] Task ID: %s',
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to check running task] Task ID: %s, error: %s',
'[schedule][开始执行任务] 参数 %s':
'[schedule][Starting task] Arguments: %s',
'[schedule][任务标准错误] 命令: %s, 信息: %s':
'[schedule][Task stderr] Command: %s, message: %s',
'[schedule][执行任务失败] 命令: %s, 错误: %s':
'[schedule][Task failed] Command: %s, error: %s',
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
'[schedule][Task finished] Task ID: %s, exit code: %j',
'[schedule][创建任务失败] 命令: %s, 错误: %s':
'[schedule][Failed to create task] Command: %s, error: %s',
'检查更新失败,请检查网络后重试':
'Failed to check for updates. Check your network and try again.',
'资源已存在,请检查重复的名称或值':
'Resource already exists; check for duplicate names or values',
暂无权限: 'Access denied',
参数错误: 'Invalid parameter',
参数不正确: 'Invalid parameter',
文件无法访问: 'File not accessible',
文件不存在: 'File not found',
路径不存在: 'Path does not exist',
路径不正确: 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
'通知发送成功': 'Notification sent successfully',
'设置时区失败': 'Failed to set timezone',
'任务未找到': 'Task not found',
'密码不能设置为admin': 'Password cannot be admin',
'更新成功': 'Update successful',
'未知错误': 'Unknown error',
'验证失败': 'Verification failed',
'实例不存在或已停止': 'Instance does not exist or stopped',
'实例已停止': 'Instance stopped',
'确认停止实例': 'Confirm to stop instance',
'确认停止运行实例': 'Confirm to stop running instance',
'确认停止': 'Confirm to stop',
'确认停止定时任务': 'Confirm to stop scheduled task',
'确认删除': 'Confirm to delete',
'确认删除定时任务': 'Confirm to delete scheduled task',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
'确认运行': 'Confirm to run',
'确认运行定时任务': 'Confirm to run scheduled task',
'确认保存': 'Confirm to save',
'确认保存文件': 'Confirm to save file',
'确认重启': 'Confirm restart',
'确认启用': 'Confirm to enable',
'确认禁用': 'Confirm to disable',
'确认': 'Confirm',
'删除成功': 'Deleted successfully',
'操作成功': 'Operation successful',
'参数不完整': 'Incomplete parameters',
'默认路径不支持删除': 'Default path cannot be deleted',
'必须在日志目录下': 'Must be within log directory',
'通知发送失败,请检查系统设置/通知配置':
'Notification failed, check system settings',
通知发送成功: 'Notification sent successfully',
设置时区失败: 'Failed to set timezone',
任务未找到: 'Task not found',
密码不能设置为admin: 'Password cannot be admin',
更新成功: 'Update successful',
未知错误: 'Unknown error',
验证失败: 'Verification failed',
实例不存在或已停止: 'Instance does not exist or stopped',
实例已停止: 'Instance stopped',
确认停止实例: 'Confirm to stop instance',
确认停止运行实例: 'Confirm to stop running instance',
确认停止: 'Confirm to stop',
确认停止定时任务: 'Confirm to stop scheduled task',
确认删除: 'Confirm to delete',
确认删除定时任务: 'Confirm to delete scheduled task',
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
确认运行: 'Confirm to run',
确认运行定时任务: 'Confirm to run scheduled task',
确认保存: 'Confirm to save',
确认保存文件: 'Confirm to save file',
确认重启: 'Confirm restart',
确认启用: 'Confirm to enable',
确认禁用: 'Confirm to disable',
确认: 'Confirm',
删除成功: 'Deleted successfully',
操作成功: 'Operation successful',
参数不完整: 'Incomplete parameters',
默认路径不支持删除: 'Default path cannot be deleted',
必须在日志目录下: 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will',
'秒后自动刷新': 'refresh in seconds',
系统将在: 'System will',
秒后自动刷新: 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
'不支持当前依赖类型': 'Unsupported dependency type',
'依赖已存在': 'Dependency already exists',
'依赖不存在': 'Dependency does not exist',
'该脚本正在运行中': 'Script is running',
'该脚本未在运行中': 'Script is not running',
'文件内容为空': 'File content is empty',
'文件名不能为空': 'File name cannot be empty',
'标签不能为空': 'Label cannot be empty',
'名称不能为空': 'Name cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword',
'名称已存在': 'Name already exists',
'密码错误': 'Incorrect password',
'用户不存在': 'User does not exist',
'请输入用户名和密码': 'Please enter username and password',
'无权访问': 'Access denied',
'登录成功': 'Login successful',
'退出成功': 'Logout successful',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
'Each entry must have name and value, see export format',
不支持当前依赖类型: 'Unsupported dependency type',
依赖已存在: 'Dependency already exists',
依赖不存在: 'Dependency does not exist',
该脚本正在运行中: 'Script is running',
该脚本未在运行中: 'Script is not running',
文件内容为空: 'File content is empty',
文件名不能为空: 'File name cannot be empty',
标签不能为空: 'Label cannot be empty',
名称不能为空: 'Name cannot be empty',
名称不能为保留关键字: 'Name cannot be reserved keyword',
名称已存在: 'Name already exists',
密码错误: 'Incorrect password',
用户不存在: 'User does not exist',
请输入用户名和密码: 'Please enter username and password',
无权访问: 'Access denied',
登录成功: 'Login successful',
退出成功: 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled',
'两步骤验证已关闭': '2FA disabled',
'验证码错误': 'Invalid verification code',
'验证码已过期': 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
'用户已存在': 'User already exists',
'用户名不能为admin': 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself',
'文件路径无效': 'Invalid file path',
'保存成功': 'Saved successfully',
两步骤验证已开启: '2FA enabled',
两步骤验证已关闭: '2FA disabled',
验证码错误: 'Invalid verification code',
验证码已过期: 'Verification code expired',
请先开启两步骤验证: 'Please enable 2FA first',
两步骤验证密钥不能为空: '2FA secret cannot be empty',
用户已存在: 'User already exists',
用户名不能为admin: 'Username cannot be admin',
不能删除自己: 'Cannot delete yourself',
不能禁用自己: 'Cannot disable yourself',
文件路径无效: 'Invalid file path',
保存成功: 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
订阅执行完成: 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first',
请先登录: 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
未分类: 'Uncategorized',
任务重复运行: 'Duplicate task execution',
日志设置为忽略: 'Log set to ignore',
定时规则不能为空: 'Schedule rule cannot be empty',
无效的定时规则: 'Invalid schedule rule',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试':
'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
端: '',
青龙快讯: 'QingLong',
登录通知: 'Login Notification',
你于: 'You at ',
在: ' in ',
端: ' client',
内网IP: 'Private IP',
登录失败: 'login failed',
',ip地址': ', IP: ',
'任务#%s': 'Task#%s',
@@ -131,12 +254,13 @@ const messages: Record<string, Record<string, string>> = {
青龙: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token',
'删除日志': 'Delete logs',
生成token: 'Generate token',
删除日志: 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
'## 执行结束... %s 耗时 %s 秒':
'## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
@@ -152,9 +276,18 @@ const messages: Record<string, Record<string, string>> = {
let currentLang: string = 'zh';
export function setLang(lang: string) {
export function setLang(lang: string, persist = true) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
if (!persist) return;
// Translation is also used by standalone scheduler and CLI helpers. Load
// persistent storage only when changing the language, not when reading it.
const { shareStore } = require('./store') as typeof import('./store');
const language = currentLang;
return shareStore.setLang(language).then(() => {
if (currentLang === language && process.connected && process.send) {
process.send({ type: 'language-changed', lang: language }, () => {});
}
});
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
+32
View File
@@ -0,0 +1,32 @@
import cluster from 'cluster';
import { setLang, systemLang } from './i18n';
function languageMessage(
message: unknown,
): message is { type: string; lang: string } {
const value = message as { type?: string; lang?: string } | null;
return (
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
);
}
// Each worker owns a translation cache. Relay saved settings to live workers;
// a replacement worker restores the persisted language before serving requests.
export async function initializeLanguageSync() {
if (cluster.isPrimary) {
cluster.on('message', (_worker, message) => {
if (!languageMessage(message)) return;
setLang(message.lang, false);
for (const worker of Object.values(cluster.workers || {})) {
if (worker?.isConnected()) worker.send(message, () => {});
}
});
return;
} else {
process.on('message', (message) => {
if (languageMessage(message)) setLang(message.lang, false);
});
}
const { shareStore } = await import('./store');
setLang((await shareStore.getLang()) || systemLang(), false);
}
+48
View File
@@ -0,0 +1,48 @@
import { Op } from 'sequelize';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
attributes: ['id'],
order: [['id', 'DESC']],
offset: 200,
limit: 1,
});
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}
+18 -10
View File
@@ -79,29 +79,36 @@ class TaskLimit {
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount + 1,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
});
this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
Logger.error(
t('[schedule][任务处理错误] 参数 %s'),
JSON.stringify(error),
);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`);
Logger.info(t(`[schedule][任务队列] 空闲中...`));
});
}
@@ -172,13 +179,14 @@ class TaskLimit {
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
t('[schedule][任务重复运行] 通知失败 %s'),
JSON.stringify(err),
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
return;
}
this.queuedCrons.set(cron.id, result);
+96 -33
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;
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
// A single-instance trigger must not interrupt work already in progress.
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
@@ -29,55 +29,118 @@ export function runCron(
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{
if (allowSingleInstances) {
// A queued manual run is an active reservation, even if its stored PID
// belongs to a previous execution.
if (
existingCron.status === CrontabStatus.queued &&
existingCron.pid
) {
Logger.info(
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
if (
existingCron.status === CrontabStatus.running &&
existingCron.pid
) {
let missing = false;
try {
process.kill(existingCron.pid, 0);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') missing = true;
else if (code !== 'EPERM') throw error;
}
if (!missing) {
Logger.info(
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
// Capture old instance IDs before releasing the stale cron state so
// a concurrent replacement cannot have its instance marked stopped.
const staleInstances = await RunningInstanceModel.findAll({
attributes: ['id'],
where: {
cron_id: Number(cron.id),
pid: existingCron.pid,
status: InstanceStatus.running,
},
},
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
});
if (!isCurrent()) return;
const [recovered] = await CrontabModel.update(
{
status: CrontabStatus.idle,
pid: null,
queued_token: null,
} as any,
{
where: {
id: Number(cron.id),
pid: existingCron.pid,
status: CrontabStatus.running,
...(existingCron.log_path !== undefined
? { log_path: existingCron.log_path }
: {}),
...(existingCron.queued_token !== undefined
? { queued_token: existingCron.queued_token }
: {}),
...(existingCron.last_execution_time !== undefined
? { last_execution_time: existingCron.last_execution_time }
: {}),
...(existingCron.isDisabled !== undefined
? { isDisabled: existingCron.isDisabled }
: {}),
allow_multiple_instances: 0,
},
},
);
if (recovered !== 1) return;
const staleIds = staleInstances.map((instance) => instance.id!);
if (staleIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: staleIds, status: InstanceStatus.running } },
);
}
Logger.info(
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
}
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
);
throw error;
}
// In particular, invalidation can happen while stopping a previous run.
// Invalidation can happen during the asynchronous database check.
if (!isCurrent()) return;
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
t('[schedule][开始执行任务] 参数 %s'),
JSON.stringify({
...cron,
command: cmd,
})}`,
}),
);
const cp = spawn(cmd, { shell: '/bin/bash' });
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
'[schedule][任务标准错误] 命令: %s, 信息: %s',
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
cmd,
message,
);
@@ -86,20 +149,20 @@ export function runCron(
const result = await completed;
if (result.error) {
Logger.error(
'[schedule][执行任务失败] 命令: %s, 错误: %s',
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
cmd,
result.error.message,
);
}
Logger.info(
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误: %s',
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
cmd,
asError(error).message,
);
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from 'fs';
import path from 'path';
import { createHash, randomUUID } from 'crypto';
function read(filename: string): Buffer | undefined {
try {
return readFileSync(filename);
} catch {
return undefined;
}
}
function manifest(): { sourceCommit?: string } {
try {
const value = JSON.parse(
read(path.resolve(__dirname, '../../build-info.json'))?.toString() ||
'{}',
);
return value && typeof value === 'object' ? value : {};
} catch {
return {};
}
}
// Capture before serving requests. Reading the installed files on each request
// would let an old process claim the identity of a newly copied build.
const backend = read(path.resolve(__dirname, '../app.js'));
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
const version = read(path.join(root, 'version.yaml'))
?.toString()
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
export const runtimeBuild = Object.freeze({
instanceId: randomUUID(),
parentPid: process.ppid,
version,
sourceCommit: manifest().sourceCommit,
backendSha256: backend
? createHash('sha256').update(backend).digest('hex')
: undefined,
});
+79
View File
@@ -0,0 +1,79 @@
import { t } from './i18n';
// These names control the task runner or the interpreters it starts. Values
// managed through the environment API must remain task data, not executable
// configuration.
const reservedNames = new Set([
'TASK_BEFORE',
'TASK_AFTER',
'WORK_DIR',
'TASK_SHELL_PARAMS',
'SCRIPT_PARAMS',
'WHICH_PROGRAM',
'ISJSORPYTHONFILE',
'EXPORTED_VARIABLES',
'FOUND_DOUBLE_DASH',
'LOG_DIR',
'LOG_PATH',
'ID',
'BEGIN_TIMESTAMP',
'TIME_FORMAT',
'MTIME_FORMAT',
'IS_MACOS',
'RANDOMDELAY',
'RANDOMDELAYFILEEXTENSIONS',
'RANDOMDELAYIGNOREDMINUTES',
'COMMAND_TIMEOUT_TIME',
'SET_U_ON',
'_TASK_EXIT_CODE',
'PATH',
'HOME',
'SHELL',
'ENV',
'BASH_ENV',
'BASHOPTS',
'SHELLOPTS',
'IFS',
'CDPATH',
'GLOBIGNORE',
'PROMPT_COMMAND',
'PS4',
'NODE_OPTIONS',
'NODE_PATH',
'PREV_NODE_OPTIONS',
'PREV_NODE_PATH',
'PREV_PYTHONPATH',
'GIT_SSH',
'GIT_SSH_COMMAND',
'GIT_ASKPASS',
'GIT_EXEC_PATH',
'GIT_TEMPLATE_DIR',
'SSH_ASKPASS',
]);
export function isAllowedTaskEnvName(name: unknown): name is string {
if (typeof name !== 'string' || !/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(name)) {
return false;
}
const upper = name.toUpperCase();
return (
!reservedNames.has(upper) &&
!upper.startsWith('BASH_') &&
!upper.startsWith('QL') &&
!upper.startsWith('FILE_') &&
!upper.startsWith('DIR_') &&
!upper.startsWith('LD_') &&
!upper.startsWith('DYLD_') &&
!upper.startsWith('PYTHON') &&
!upper.startsWith('GIT_CONFIG_')
);
}
export function assertAllowedTaskEnvName(name: unknown): asserts name is string {
if (!isAllowedTaskEnvName(name)) {
throw Object.assign(new Error(t('环境变量名称不可使用')), {
status: 400,
});
}
}
+5 -4
View File
@@ -1,6 +1,7 @@
import express, { Application, Request } from 'express';
import { AuthDataType, SystemModel } from '../data/system';
import { normalizeClientIp } from './clientIp';
import { t } from './i18n';
const DEFAULT_TRUST_PROXY = 'loopback';
@@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
function validateTrustProxy(value: string): string {
const setting = normalizeSetting(value);
if (setting.length > 500 || /[\r\n]/.test(setting)) {
throw new Error('trust proxy 配置格式无效');
throw new Error(t('trust proxy 配置格式无效'));
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error('代理层数不能超过 20');
throw new Error(t('代理层数不能超过 20'));
}
const probe = express();
@@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) {
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
}
const trustProxy = validateTrustProxy(value);
@@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) {
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error('系统配置不存在');
throw new Error(t('系统配置不存在'));
}
const plain = doc.get({ plain: true });
+23 -1
View File
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
});
export const commonCronSchema = {
@@ -30,7 +32,27 @@ export const commonCronSchema = {
schedule: scheduleSchema,
labels: Joi.array().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
extra_schedules: Joi.array()
.items(
Joi.object({
schedule: Joi.string()
.required()
.custom((value, helpers) =>
isValidCronSchedule(value) ? value : helpers.error('any.invalid'),
)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
}),
})
.unknown(true)
.messages({ 'object.base': '额外定时规则必须是对象' }),
)
.optional()
.allow(null)
.messages({ 'array.base': '额外定时规则必须是数组' }),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
+8 -20
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. Remote CLI commands do not manage development publishing.
`@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.
## Installation and commands
@@ -43,30 +43,18 @@ 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 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.
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.
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
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
@@ -80,7 +68,7 @@ Protected requests use `Authorization: Bearer <token>`. Two credential sources a
### Application login
Create a dedicated panel application with the required scopes, such as crons, subscriptions or envs. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
Create a dedicated panel application with the required scopes. Existing scopes retain their original permissions. For read-only access, use `crons:read`, `subscriptions:read`, `envs:read`, `configs:read`, `scripts:read`, `dependencies:read`, `logs:read`, or `dashboard:read`. Script and log downloads use POST but are available to their read-only scopes. `GET /crons/import` writes tasks and is excluded from `crons:read`. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
```sh
ql login --url https://ql.example.com
@@ -88,7 +76,7 @@ ql auth status --scope crons --json
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
```
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
Credentials/token are stored in `~/.config/qinglong/cli.json` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
### Direct access token
@@ -140,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. 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 package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing 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.
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
+8 -20
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,30 +43,18 @@ ql auth logout --json
## 全量 OpenAPI 管理
现在还支持任务/订阅创建、修改、删除,应用管理与密钥重置,以及环境变量、配置、脚本、日志、依赖、系统、仪表盘和用户管理。`ql api routes --json` 列出全部 146 条有效路由;3 条已下线文件读取接口不包含在内。新增命令在 [完整双语参考](skills/qinglong-cli/references/openapi.md) 中逐项列出,路由覆盖由 CI 与后端代码核对。
现在还支持任务/订阅创建、修改、删除,应用管理与密钥重置,以及环境变量、配置、脚本、日志、依赖、系统、仪表盘和用户管理。`ql api routes --json` 列出全部 143 条有效路由;3 条已下线文件读取接口不包含在内。新增命令在 [完整双语参考](skills/qinglong-cli/references/openapi.md) 中逐项列出,路由覆盖由 CI 与后端代码核对。
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
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 覆盖理解为本机运维重新混入包。
## 认证
@@ -80,7 +68,7 @@ ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_
### 应用凭据登录
在面板创建专用应用,按需授予 crons、subscriptions、envs 等权限。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
在面板创建专用应用,按需授予 crons、subscriptions 等权限。旧 scope 保留原有权限;仅读取时可选用 `crons:read`、`subscriptions:read`、`envs:read`、`configs:read`、`scripts:read`、`dependencies:read`、`logs:read`、`dashboard:read`。脚本和日志下载虽使用 POST,也允许对应只读权限;`GET /crons/import` 会写入任务,不属于只读操作。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
```sh
ql login --url https://ql.example.com
@@ -88,7 +76,7 @@ ql auth status --scope crons --json
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
```
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
### 直接使用访问令牌
@@ -140,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动发布在 master 勾选 publish,或在 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 package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已存在的稳定版本提示并跳过;预发布版本已存在则失败,需选择新版本。registry 查询失败停止发布。手动 develop 预发布只临时修改本轮验证工作区的版本,不代替稳定版本元数据维护。发布不重新构建已验证归档,也不执行包生命周期脚本。
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
## 面板内部工具
+78 -4
View File
@@ -1,12 +1,13 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"version": "0.1.4",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
},
@@ -14,12 +15,12 @@
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"tar": "7.5.20",
"typescript": "5.2.2"
},
"engines": {
"node": ">=22.12.0"
},
"license": "Apache-2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
@@ -463,6 +464,19 @@
"node": ">=18"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
@@ -473,6 +487,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
@@ -525,6 +549,46 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -545,6 +609,16 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"version": "0.1.4",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
@@ -26,7 +26,8 @@
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0"
"@types/node": "22.19.0",
"tar": "7.5.20"
},
"license": "Apache-2.0",
"repository": {
+19
View File
@@ -17,6 +17,25 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/nodeRuntime.cjs'),
path.join(root, 'dist/internal/maintenance/nodeRuntime.cjs'),
);
// The operational CLI may live outside the panel and has no panel node_modules.
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'),
+18 -5
View File
@@ -1,16 +1,18 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
常规使用 `ql login` 的应用凭据。scope 是 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。应用管理需要 apps;user、health、update 等路由同样接受服务端权限校验。面板 UI 当前没有列出全部这些 scope,不能把路由存在理解成现有应用已获授权。
常规使用 `ql login` 的应用凭据。scope 通常对应 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。旧 scope 保留原有权限;上述资源除 system 外可选用对应的 `:read` scope。`crons:read` 不包含会写入任务的 `GET /crons/import`;`scripts:read`、`logs:read` 包含使用 POST 的下载,不包含写入、删除或执行。`dashboard:read` 不允许 POST 统计上报。外部应用的 `system` scope 仅允许读取系统状态和发送通知。应用管理需要 apps;`GET /apps` 会返回 Client Secret,因此不提供 `apps:read`。user、health、update 等路由同样接受服务端权限校验。路由存在不代表现有应用已获授权。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用 apps 权限的应用或授权面板会话;QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用已有 apps 权限的应用或授权面板会话;后端创建和更新应用时拒绝授予 apps 权限。QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts apps-scoped credentials or an authorized owner session. Direct tokens are not saved/refreshed and never fall back to application configuration.
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts existing apps-scoped credentials or an authorized owner session; the backend rejects granting apps scope when creating or updating an application. Direct tokens are not saved/refreshed and never fall back to application configuration.
Existing scopes retain their original access. Use the resource's `:read` scope for read-only access to crons, subscriptions, envs, configs, scripts, dependencies, logs, or dashboard. `crons:read` excludes the mutating `GET /crons/import`; `scripts:read` and `logs:read` include downloads (POST), but exclude writes, deletion, and execution. `dashboard:read` excludes statistics reporting (POST). External `system` applications may only read system status and send notifications. `GET /apps` returns Client Secrets, so there is no `apps:read` scope.
`auth status --scope <scope>` tests a representative read; update has no read endpoint and is not offered by status. This does not prove permission for every write. `app list/create/update/reset-secret` 默认隐藏 client_secret/tokens;只有明确需要凭据时才加 --show-secrets,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
@@ -49,16 +51,24 @@ ql script get --query '{"file":"demo.js","path":""}' --json
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
# Requires the full configs scope or a panel session token.
ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -151,6 +161,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
+8 -8
View File
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
" if (task_before) console.log('执行前置命令\\n');",
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
' console.log(output);',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' } catch (error) {',
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
' // environment variable is too large',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' }',
'',
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("执行前置命令\\n")',
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
'source "$file" "$@" &&',
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
' if len(output) > 0:',
' print(output)',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except OSError as error:',
' error_message = str(error)',
' if "Argument list too long" not in error_message:',
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
' # else:',
' # environment variable is too large',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' import task_before',
'',
+6 -6
View File
@@ -8,7 +8,6 @@ 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,
@@ -93,13 +92,10 @@ 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'),
]
.filter(Boolean)
.join(path.delimiter),
].join(path.delimiter),
PYTHONPATH: [
python,
path.join(python, `lib/python${pythonVersion}`),
@@ -180,7 +176,11 @@ export async function bootstrapPanel(
root ? args : [packages.program, ...args],
{ cwd: context.root, env: context.env },
);
await installRuntimeTools(context);
await checkedProcess(
'npm',
['install', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
}
const python = await checkedProcess(
'python3',
+6 -2
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 { installRuntimeTools } from './tools';
import { checkedProcess } from '../runtime/process';
import {
repairConfiguration,
installPanelDependencies,
@@ -152,7 +152,11 @@ export async function diagnosticLog(file: string): Promise<{
}
export async function checkAndRepair(context: LocalContext): Promise<unknown> {
await installRuntimeTools(context);
await checkedProcess(
'npm',
['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
const restored = await repairConfiguration(context);
await installPanelDependencies(context);
const copied: string[] = [];
@@ -0,0 +1,18 @@
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
const armv6ServerArgs = Object.freeze([
'--no-opt',
'--no-sparkplug',
'--no-maglev',
]);
function serverRuntimeArgs(runtime = process) {
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6 &&
Number(runtime.versions.node.split('.')[0]) >= 24
? [...armv6ServerArgs]
: [];
}
module.exports = { serverRuntimeArgs, armv6ServerArgs };
if (require.main === module)
process.stdout.write(serverRuntimeArgs().join(' '));
+24 -12
View File
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { runProcess, checkedProcess } from '../runtime/process';
import { findDirectBackendPids } from '../runtime/backendProcesses';
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
serverRuntimeArgs(): string[];
armv6ServerArgs: readonly string[];
};
export async function repairConfiguration(
context: LocalContext,
@@ -71,18 +75,14 @@ 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(
process.execPath,
[
path.join(context.root, 'scripts/install-panel-dependencies.cjs'),
'--root',
cwd,
...(staticRoot ? ['--static-dir', staticRoot] : []),
...(sourceCommit ? ['--source-commit', sourceCommit] : []),
],
pnpm ? 'pnpm' : 'npm',
pnpm
? ['install', '--loglevel', 'error', '--production']
: ['install', '--production', ...(termux ? ['--no-bin-links'] : [])],
{ cwd, env: context.env },
);
}
@@ -121,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
if (match[3] !== entry) continue;
if (
match[3] !== entry &&
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
)
continue;
const pid = Number(match[1]);
if (pid === process.pid) continue;
try {
@@ -154,6 +158,14 @@ export async function startPanel(
GRPC_PORT: context.env.QlGrpcPort || '5500',
},
};
// Archive selection and launcher ownership apply only to the upgrade worker.
// Retaining them in PM2 would pin the next online update to an offline file.
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete options.env[name as keyof typeof options.env];
if (await executableAvailable('pm2', context.env)) {
const flushed = await runProcess('pm2', ['flush'], options);
if (flushed.code === 0) {
@@ -175,7 +187,7 @@ export async function startPanel(
0o600,
);
try {
const child = spawn(process.execPath, [entry], {
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
...options,
detached: true,
stdio: ['ignore', log.fd, log.fd],
@@ -0,0 +1,577 @@
// Persistent panel jobs run independently of HTTP requests and browser sessions.
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs');
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
const launches = new Map();
async function withPanelLifecycle(root, tmp, env, operation, signal) {
if (!env.QL_CLI_ROOT)
return artifacts.withLifecycleHooks(root, tmp, env, operation);
if (!path.isAbsolute(env.QL_CLI_ROOT))
throw new Error('QL_CLI_ROOT must be absolute');
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
// Keep the selected installation's operators loaded across file replacement.
const { createContext, sourceEnvironment } = require(path.join(
internal,
'runtime/context.js',
));
const { cancellableOperation } = require(path.join(
internal,
'runtime/cancellation.js',
));
const operator = require(path.join(internal, 'maintenance/operator.js'));
const { prepareUpgradeSelection } = require(path.join(
internal,
'maintenance/upgradeSelection.js',
));
const context = createContext(
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
env,
);
context.env = await sourceEnvironment(
context.env,
[
context.paths.file_config_user,
path.join(context.paths.dir_preload, 'lang_env.sh'),
],
[],
{ signal },
);
const hook = (name, args = [], signal) =>
cancellableOperation(signal, () => {
if (name === 'install')
return operator.installPanelDependencies(context, args[0]);
if (name === 'stop') return operator.stopPanel(context);
if (name === 'start') return operator.startPanel(context);
throw new Error('Invalid lifecycle operation');
});
return operation(hook, (selected) =>
prepareUpgradeSelection(context, selected.source, selected.static),
);
}
async function targetVersion(source) {
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
const version = text.match(
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
if (!version) throw new Error('Invalid update version');
return version;
}
async function readRunningBuild(env) {
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
base = base.replace(/\/$/, '');
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => request.destroy(new Error('Version check timed out')),
5000,
);
const request = http.get(
{
hostname: '127.0.0.1',
port: Number(env.QlPort || 5700),
path: `${base}/api/health`,
},
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
if (body.length > 65536)
request.destroy(new Error('Invalid version response'));
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok'
)
throw new Error('后端启动检查失败。');
resolve(result.data.build);
} catch (error) {
reject(error);
}
});
},
);
request.on('error', reject);
request.on('close', () => clearTimeout(timer));
});
}
async function verifyRunningVersion(env, expected, previousBuild) {
const build = await readRunningBuild(env);
if (
!build ||
!build.instanceId ||
build.instanceId === previousBuild?.instanceId ||
(previousBuild?.parentPid !== undefined &&
build.parentPid === previousBuild.parentPid) ||
build.version !== expected.version ||
build.sourceCommit !== expected.sourceCommit ||
build.backendSha256 !== expected.backendSha256
)
throw new Error('启动版本与更新包不一致');
}
async function stagedMetadata(selected) {
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
);
return {
version: await targetVersion(selected.source),
sourceCommit: manifest.sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: path.basename(path.dirname(selected.source)),
};
}
function assertSelected(expected, actual) {
for (const key of [
'sourceCommit',
'backendSha256',
'stageDirectory',
'version',
]) {
if (expected[key] !== actual[key])
throw new Error('暂存更新已变化,请重新下载更新。');
}
}
async function save(tmp, state) {
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
await fs.rename(temporary, stateFile(tmp));
}
async function readState(tmp) {
try {
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { status: 'idle' };
throw error;
}
}
async function getStatus(tmp) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
try {
process.kill(state.pid, 0);
alive = true;
} catch (error) {
alive = error.code === 'EPERM';
}
} else alive = Date.now() - state.startedAt < 10000;
if (!alive) {
state.status = 'failed';
state.error = '升级任务意外中断,请重试';
}
}
// Bound reads even when an installer produces a very large log.
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
if (handle) {
try {
const { size } = await handle.stat();
const buffer = Buffer.alloc(Math.min(size, 32768));
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length,
Math.max(0, size - buffer.length),
);
state.log = buffer.subarray(0, bytesRead).toString();
} finally {
await handle.close();
}
}
return state;
}
// Manual restarts accept the currently prepared package, including one retained
// after a failed preparation. Legacy installs without jobs keep their old path;
// complete-bundle installs always verify and record the actual running build.
async function withPreparedJob(tmp, env, selected, operation) {
const previous = await getStatus(tmp);
if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。');
const hasRecord = await fs
.access(stateFile(tmp))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
if (!hasRecord) {
const pkg = env.QL_DIR
? JSON.parse(
await fs
.readFile(path.join(env.QL_DIR, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
)
: {};
if (pkg.qinglongUpgradeFormat !== 2) return operation();
}
return artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const current = await getStatus(tmp);
if (active.has(current.status))
throw new Error('已有更新或重载正在执行。');
const prepared = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
if (
prepared.source !== selected.source ||
prepared.static !== selected.static
)
throw new Error('暂存更新已变化,请重新下载更新。');
const metadata = await stagedMetadata(selected);
const previousBuild = await readRunningBuild(env).catch(() => undefined);
const { log, error, ...record } = current;
let state = {
...record,
...metadata,
id: randomUUID(),
action: 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
pid: process.pid,
};
const report = async (patch) => {
state = { ...state, ...patch, updatedAt: Date.now() };
await save(tmp, state);
};
await fs.writeFile(
logFile(tmp),
'Manual application of prepared update\n',
{
mode: 0o600,
},
);
await report({});
try {
const result = await operation({
onStop: () => report({ status: 'restarting', phase: 'restarting' }),
onStart: (recover) =>
recover
? undefined
: verifyRunningVersion(env, state, previousBuild),
});
await report({ status: 'completed', phase: 'completed' });
return result;
} catch (error) {
await report({ status: 'failed', error: error.message });
throw error;
}
},
env,
);
}
async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action');
await fs.mkdir(tmp, { recursive: true });
const preceding = launches.get(tmp) || Promise.resolve();
const launching = preceding
.catch(() => {})
.then(() =>
artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const previous = await getStatus(tmp);
if (active.has(previous.status)) {
if (previous.action !== action)
throw new Error('已有更新或重载正在执行。');
return previous;
}
let selected;
let metadata;
if (action === 'apply') {
// Keep requests short on slow devices. The detached worker performs
// complete file/dependency verification before it can stop the panel.
try {
selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
metadata = await stagedMetadata(selected);
if (previous.status === 'ready')
assertSelected(previous, metadata);
} catch (error) {
const { log, ...record } = previous;
await save(tmp, {
...record,
status: 'failed',
phase: 'verifying',
error: error.message,
});
throw error;
}
} else if (previous.status === 'ready') return previous;
const state = {
id: randomUUID(),
action,
status: 'starting',
phase: action === 'apply' ? 'verifying' : 'downloading',
startedAt: Date.now(),
...metadata,
};
await save(tmp, state);
const log = await fs.open(logFile(tmp), 'w', 0o600);
try {
const child = spawn(
process.execPath,
[__filename, 'launch', state.id],
{
env: { ...env, QL_DIR: root, dir_tmp: tmp },
detached: true,
stdio: ['ignore', log.fd, log.fd],
},
);
await new Promise((resolve, reject) => {
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(`Upgrade launcher exited (${signal || code})`),
);
});
child.once('error', reject);
});
child.unref();
} catch (error) {
await save(tmp, {
...state,
status: 'failed',
error: error.message,
});
throw error;
} finally {
await log.close();
}
return state;
},
env,
),
);
launches.set(tmp, launching);
try {
return await launching;
} finally {
if (launches.get(tmp) === launching) launches.delete(tmp);
}
}
async function runJob({ root, tmp, env = process.env }, id) {
let state = await readState(tmp);
if (state.id !== id || state.status !== 'starting')
throw new Error('Stale upgrade job');
const controller = new AbortController();
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
process.once('SIGTERM', abort);
process.once('SIGINT', abort);
// Serialize progress writes so an older tick cannot overwrite ready/completed.
let writes = Promise.resolve();
const report = (patch) => {
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
const snapshot = { ...state };
writes = writes.then(() => save(tmp, snapshot));
return writes;
};
try {
await report({ status: 'running' });
let terminal;
await artifacts.withUpgradeLock(
tmp,
() =>
withPanelLifecycle(
root,
tmp,
env,
async (hook, prepareSelection) => {
const run = (program, args, capture) =>
artifacts.runProcess(
env,
program,
args,
capture,
controller.signal,
);
if (state.action === 'download') {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env,
mirror: 'github',
fallbackMirror: 'gitee',
run,
signal: controller.signal,
onProgress: report,
install: (source) =>
hook('install', [source], controller.signal),
});
terminal = {
status: 'ready',
phase: 'ready',
...(await stagedMetadata(selected)),
};
} else {
const selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
);
assertSelected(state, await stagedMetadata(selected));
const previousBuild = await readRunningBuild(env).catch(
() => undefined,
);
const selection = await prepareSelection?.(selected);
try {
await artifacts.reloadSystem({
root,
tmp,
env,
data:
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config:
env.dir_config ||
path.join(
env.QL_DATA_DIR || path.join(root, 'data'),
'config',
),
lifecycle: {
signal: controller.signal,
stop: async () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop');
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
env,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
await verifyRunningVersion(env, state, previousBuild);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
terminal = { status: 'completed', phase: 'completed' };
}
},
controller.signal,
),
env,
);
await report(terminal);
} catch (error) {
console.error(error);
// A failed write must not prevent recording the final failure.
await writes.catch(() => {});
writes = Promise.resolve();
await report({ status: 'failed', error: error.message });
} finally {
process.removeListener('SIGTERM', abort);
process.removeListener('SIGINT', abort);
await writes;
}
}
module.exports = {
getStatus,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
withPreparedJob,
};
async function launchWorker(id) {
// detached creates a session but keeps the original parent. PM2 kills the
// backend's entire descendant tree, so an intermediate launcher must exit
// before the worker is allowed to stop that backend.
const child = spawn(
process.execPath,
[...nodeRuntimeArgs(), __filename, 'worker', id],
{
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
},
);
await new Promise((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
}
async function runOrphanedWorker(id) {
const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID);
const deadline = Date.now() + 10000;
while (process.platform !== 'win32' && process.ppid === launcher) {
if (Date.now() >= deadline)
throw new Error('Upgrade launcher did not exit');
await delay(10);
}
const root = process.env.QL_DIR;
await runJob(
{ root, tmp: process.env.dir_tmp || path.join(root, '.tmp') },
id,
);
}
if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) {
const operation =
process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker;
operation(process.argv[3]).catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
@@ -0,0 +1,582 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const os = require('node:os');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const tar = require('tar');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
const {
prestartBackend,
dependencyFailure,
repairDependencies,
} = require('./upgradePrestart.cjs');
const execute = promisify(execFile);
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
// upgrade workers interpreted without changing the installed Node or server.
const nodeRuntimeArgs = (runtime = process) =>
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
? ['--jitless']
: [];
const sha256 = (bytes) =>
crypto.createHash('sha256').update(bytes).digest('hex');
const inside = (root, filename) => {
const relative = path.relative(root, filename);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
};
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
function atLeast(actual, minimum) {
const a = versionParts(actual),
b = versionParts(minimum);
return (
b.every((n) => Number.isFinite(n)) &&
a.every((n) => Number.isFinite(n)) &&
(a[0] > b[0] ||
(a[0] === b[0] &&
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
);
}
async function runtimeTarget() {
let libc, libcVersion;
if (process.platform === 'linux') {
const header = process.report.getReport().header;
if (header.glibcVersionRuntime) {
libc = 'glibc';
libcVersion = header.glibcVersionRuntime;
} else {
let text = '';
try {
text = (await execute('ldd', ['--version'])).stdout;
} catch (error) {
text = `${error.stdout || ''}\n${error.stderr || ''}`;
}
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
if (!match)
throw new Error(
'Cannot identify Linux libc; refusing a native dependency bundle',
);
libc = 'musl';
libcVersion = match[1];
}
}
const armVersion =
process.arch === 'arm'
? Number(process.config.variables.arm_version)
: undefined;
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
throw new Error('Cannot identify ARM instruction set');
const arch =
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
const armFloatAbi =
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
if (
process.arch === 'arm' &&
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
)
throw new Error('Cannot identify ARM floating-point ABI');
return {
platform: process.platform,
arch,
libc,
libcVersion,
endianness: os.endianness(),
armFloatAbi,
};
}
function targetKey(target) {
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
}
function assertCompatible(manifest, target, node = process.versions.node) {
if (
manifest.format !== 2 ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files) ||
!Number.isSafeInteger(manifest.nodeApi) ||
manifest.nodeApi < 6
)
throw new Error('Invalid platform bundle manifest');
if (targetKey(manifest.target || {}) !== targetKey(target))
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
if (
!atLeast(node, manifest.nodeMinimum) ||
Number(process.versions.napi) < manifest.nodeApi
)
throw new Error(
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
);
if (
manifest.target.endianness !== target.endianness ||
manifest.target.armFloatAbi !== target.armFloatAbi
)
throw new Error(
'Bundle native byte order or ARM floating-point ABI mismatch',
);
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
throw new Error(
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
);
}
async function inventory(roots) {
const files = {};
async function visit(directory, prefix) {
for (const name of (await fs.readdir(directory)).sort()) {
const filename = path.join(directory, name),
relative = `${prefix}/${name}`;
const stat = await fs.lstat(filename);
if (stat.isDirectory()) await visit(filename, relative);
else if (stat.isFile())
files[relative] = {
type: 'file',
mode: stat.mode & 0o777,
sha256: sha256(await fs.readFile(filename)),
};
else if (stat.isSymbolicLink()) {
const link = await fs.readlink(filename);
const boundary = roots[prefix.split('/')[0]];
if (
path.isAbsolute(link) ||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
)
throw new Error(`Bundle link escapes its payload: ${relative}`);
files[relative] = { type: 'link', link };
} else throw new Error(`Unsupported bundle entry: ${relative}`);
}
}
for (const [name, root] of Object.entries(roots)) await visit(root, name);
return files;
}
async function verifyPayload(selected, manifest, target) {
assertCompatible(manifest, target);
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
if (
await fs.lstat(path.join(selected.source, name)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return null;
})
)
throw new Error(`Reserved installation path in bundle: ${name}`);
}
const actual = await inventory({
source: selected.source,
static: selected.static,
});
const names = Object.keys(actual).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(names) !== JSON.stringify(expected) ||
names.some(
(name) =>
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
)
)
throw new Error(
'Platform bundle files or dependency checksums do not match',
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
await fs.access(
path.join(selected.source, 'node_modules/sqlite3/package.json'),
);
}
async function smokeDependencies(source, signal) {
// This probe is installed verifier code, never a verifier supplied by an archive.
const probe = `
const path = require('node:path'),fs = require('node:fs');
const root = fs.realpathSync(process.cwd());
const r = require('node:module').createRequire(path.join(root,'package.json'));
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
r(name);
}
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
`;
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
// WebAssembly, which --jitless disables; only archive workers need that flag.
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
// ARM1176 devices need the same startup allowance as the backend probe.
// Keep this bounded and cancellable without forcing an unnecessary repair.
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
signal,
});
}
async function extractArchive(archive, destination) {
const entries = new Map(),
links = new Set();
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
entries.has(name) ||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
)
throw new Error(`Unsafe bundle archive entry: ${name}`);
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, entry.type);
if (entry.type === 'SymbolicLink') {
if (
path.posix.isAbsolute(entry.linkpath) ||
entry.linkpath.includes('\\') ||
!inside(
'/qinglong',
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
)
)
throw new Error('Unsafe bundle symlink');
links.add(name);
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const name of entries.keys()) {
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (links.has(parent))
throw new Error('Archive writes through a symlink');
parent = path.posix.dirname(parent);
}
}
await tar.x({
file: archive,
cwd: destination,
strict: true,
preservePaths: false,
noChown: true,
umask: 0,
});
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
prestart = prestartBackend,
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
if (!local) {
const current = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
? 'develop'
: 'master';
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const report = onProgress || (async () => {});
try {
const archive = path.join(workspace, 'bundle.tar.gz');
let expectedCommit;
if (local) {
if (!path.isAbsolute(local))
throw new Error('Offline bundle path must be absolute');
// Snapshot the local input so replacing it cannot race validation/extraction.
await fs.copyFile(local, archive);
} else {
const indexUrl =
env.QL_UPGRADE_INDEX_URL ||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
const checkUrl = (url) => {
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' &&
!(
parsed.protocol === 'http:' &&
['127.0.0.1', '[::1]'].includes(parsed.hostname)
)
)
throw new Error('Upgrade URLs require HTTPS');
};
checkUrl(indexUrl);
const indexFile = path.join(workspace, 'index.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
'--proto-redir',
'=https',
'--header',
'Cache-Control: no-cache',
'--connect-timeout',
'10',
'--max-time',
'30',
'--max-filesize',
'1048576',
'--output',
indexFile,
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
!entry ||
!Number.isSafeInteger(entry.size) ||
entry.size <= 0 ||
entry.size > 512 * 1024 ** 2 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
)
throw new Error(`No verified update bundle for ${targetKey(target)}`);
checkUrl(entry.url);
expectedCommit = index.sourceCommit;
await report({
phase: 'downloading',
artifact: 'qinglong-bundle',
downloadedBytes: 0,
totalBytes: entry.size,
percent: 0,
});
await download(
run,
archive,
entry.url,
'qinglong-bundle',
report,
true,
entry,
);
}
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
throw new Error('Bundle is too large');
await report({
phase: 'extracting',
artifact: 'qinglong-bundle',
percent: null,
});
await extractArchive(archive, workspace);
const incoming = path.join(workspace, 'qinglong');
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
await fs.rename(path.join(incoming, 'source'), selected.source);
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
await report({ phase: 'dependencies', percent: null });
let dependencyFiles;
const testCandidate = async () => {
await probe(selected.source, signal);
await report({ phase: 'prestarting', percent: null });
return prestart(selected, { target, signal });
};
try {
await testCandidate();
} catch (error) {
signal?.throwIfAborted();
if (!dependencyFailure(error)) throw error;
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
throw new Error(
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
);
await report({ phase: 'repairing', percent: null });
await repair(selected.source, { target, signal, env });
const repaired = await inventory({
source: selected.source,
static: selected.static,
});
dependencyFiles = Object.fromEntries(
Object.entries(repaired).filter(([name]) =>
name.startsWith('source/node_modules/'),
),
);
// A repair may change only dependencies, never bless changed application code.
await verifyPayload(
selected,
manifestWithDependencies(manifest, dependencyFiles),
target,
);
await verifyStatic(selected.source, selected.static, env);
await testCandidate(); // Exactly one repair; this failure propagates.
}
await fs.writeFile(
path.join(workspace, 'prestart.json'),
JSON.stringify({
format: 1,
sourceCommit: manifest.sourceCommit,
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
node: process.version,
execPath: process.execPath,
completedAt: Date.now(),
...(dependencyFiles ? { dependencyFiles } : {}),
}),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'bundle.json'),
JSON.stringify(manifest),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
bundle: true,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
signal?.throwIfAborted();
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => 'null');
await fs.rename(pointer, readyPointer);
try {
const old = JSON.parse(previous)?.directory;
if (
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
old !== path.basename(workspace)
)
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
} catch {
/* Committed readiness must survive cleanup failure. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
function manifestWithDependencies(manifest, dependencyFiles) {
if (!dependencyFiles) return manifest;
if (
typeof dependencyFiles !== 'object' ||
Array.isArray(dependencyFiles) ||
!Object.keys(dependencyFiles).length ||
Object.keys(dependencyFiles).some(
(name) => !name.startsWith('source/node_modules/'),
)
)
throw new Error('Invalid repaired dependency inventory');
return {
...manifest,
files: {
...Object.fromEntries(
Object.entries(manifest.files).filter(
([name]) => !name.startsWith('source/node_modules/'),
),
),
...dependencyFiles,
},
};
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
const prestart = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
'utf8',
),
);
if (
prestart.format !== 1 ||
prestart.sourceCommit !== manifest.sourceCommit ||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
prestart.node !== process.version ||
prestart.execPath !== process.execPath ||
!Number.isSafeInteger(prestart.completedAt)
)
throw new Error(
'Upgrade prestart receipt is missing or stale; prepare the update again',
);
await verifyPayload(
selected,
manifestWithDependencies(manifest, prestart.dependencyFiles),
await runtimeTarget(),
);
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
-38
View File
@@ -1,38 +0,0 @@
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);
}
+59 -27
View File
@@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator';
// The Bash image updater and local CLI must enforce the same build provenance.
const artifacts = require('./upgradeArtifacts.cjs') as {
releaseBranch(env: NodeJS.ProcessEnv): string;
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
verifyUpgrade(
source: string,
staticRoot: string,
env?: NodeJS.ProcessEnv,
): Promise<unknown>;
waitForHealth(
env: NodeJS.ProcessEnv,
timeout?: number,
signal?: AbortSignal,
): Promise<void>;
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
withUpgradeLock<T>(
tmp: string,
operation: () => Promise<T>,
env?: NodeJS.ProcessEnv,
): Promise<T>;
stageUpgrade(options: {
root: string;
tmp: string;
@@ -25,11 +33,12 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
mirror: 'github' | 'gitee';
signal?: AbortSignal;
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
install(source: string, staticRoot: string, sourceCommit: string): Promise<void>;
install(source: string): Promise<void>;
}): Promise<{ source: string; static: string }>;
selectedUpgrade(
tmp: string,
branch: string,
env?: NodeJS.ProcessEnv,
): Promise<{ source: string; static: string }>;
};
@@ -69,18 +78,20 @@ export async function stageUpgrade(
context: LocalContext,
mirror: 'github' | 'gitee',
): Promise<{ source: string; static: string }> {
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
artifacts.stageUpgrade({
root: context.root,
tmp: context.paths.dir_tmp!,
env: context.env,
mirror,
signal: operationSignal(),
run: (program, args, capture) =>
checkedProcess(program, args, { env: context.env, capture }),
install: (source, staticRoot, sourceCommit) =>
installPanelDependencies(context, source, staticRoot, sourceCommit),
}),
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,
);
}
@@ -90,12 +101,13 @@ async function selectedUpgrade(
return artifacts.selectedUpgrade(
context.paths.dir_tmp!,
releaseBranch(context),
context.env,
);
}
export interface ReloadLifecycle {
stop(context: LocalContext): Promise<void>;
start(context: LocalContext): Promise<unknown>;
start(context: LocalContext, recover?: boolean): Promise<unknown>;
}
// All old entries stay on the same filesystem until service start succeeds.
@@ -190,7 +202,7 @@ export async function replaceAndReload(
if (item.existed) await fs.rename(item.backup, item.target);
}
try {
await lifecycle.start(context);
await lifecycle.start(context, true);
} catch {
throw new Error(
translate(
@@ -260,7 +272,7 @@ export async function reloadPanel(
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot);
await artifacts.verifyUpgrade(source, staticRoot, context.env);
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
@@ -285,15 +297,35 @@ export async function reloadPanel(
if (selection)
replacements.push({ source: selection.source, target: selection.target });
try {
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
return await artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
require('./panelUpgrade.cjs').withPreparedJob(
context.paths.dir_tmp!,
context.env,
selected,
(job?: {
onStop(): Promise<void>;
onStart(recover?: boolean): Promise<void> | undefined;
}) =>
replaceAndReload(context, replacements, {
stop: async (ctx) => {
await job?.onStop();
await stopPanel(ctx);
},
start: async (ctx, recover) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(
ctx.env,
undefined,
operationSignal(),
);
await job?.onStart(recover);
return service;
},
}),
),
context.env,
);
} finally {
if (selection)
+420 -105
View File
@@ -7,11 +7,41 @@ const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const upgradeEnglish = {
'更新包下载及校验成功...': 'Update package downloaded and verified...',
'更新及启动检查成功...':
'Update and startup checks completed successfully...',
'更新失败: %s': 'Update failed: %s',
'构建清单无效。': 'Invalid or dirty build manifest',
'缺少构建产物: %s': 'Missing build output: %s',
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
'构建包含非法文件: %s': 'Unsupported build entry: %s',
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
'已有更新或重载正在执行。': 'Another upgrade is running',
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
'升级归档路径无效。': 'Invalid archive paths',
'升级归档包含符号链接。': 'Archive contains symlinks',
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
'暂存目录无效。': 'Invalid staging directory',
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
'运行依赖已变化,请重新下载更新。':
'Installed dependencies changed; download the update again',
'保留备份: %s': 'Retained backup: %s',
'后端启动检查失败。': 'Backend failed health check',
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
};
function upgradeMessage(key, env = process.env, ...values) {
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
let index = 0;
return template.replace(/%s/g, () => String(values[index++] ?? ''));
}
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const releaseBranch = (env) =>
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
async function manifestAt(staticRoot) {
async function manifestAt(staticRoot, env = process.env) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
@@ -24,22 +54,20 @@ async function manifestAt(staticRoot) {
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
throw new Error(upgradeMessage('构建清单无效。', env));
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
return manifest;
}
async function verifyUpgrade(source, staticRoot) {
const manifest = await manifestAt(staticRoot);
async function verifyUpgrade(source, staticRoot, env = process.env) {
const manifest = await manifestAt(staticRoot, env);
if (
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error(
'Source and build lockfiles differ / 源码与构建依赖不一致。',
);
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(source, 'sample/config.sample.sh'));
const files = {};
@@ -50,8 +78,7 @@ async function verifyUpgrade(source, staticRoot) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(filename, `${name}/`);
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
else
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
}
}
await visit(staticRoot);
@@ -61,13 +88,11 @@ async function verifyUpgrade(source, staticRoot) {
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
);
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
return manifest;
}
async function withUpgradeLock(tmp, operation) {
async function withUpgradeLock(tmp, operation, env = process.env) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
@@ -123,9 +148,7 @@ async function withUpgradeLock(tmp, operation) {
}
}
if (!stale)
throw new Error(
'Another upgrade is running / 已有更新或重载正在执行。',
);
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
// Removing the observed unique marker is a compare-and-delete. A
// contender cannot unlink the marker belonging to a replacement lock.
// Legacy pid files are supported; new owners never reuse that filename.
@@ -150,31 +173,223 @@ async function withUpgradeLock(tmp, operation) {
}
}
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
async function downloadWithProgress(
run,
archive,
url,
artifact,
onProgress,
quiet = true,
expected,
) {
const headers = `${archive}.headers`;
await fs.rm(archive, { force: true });
await fs.rm(headers, { force: true });
let sampling = Promise.resolve();
const sample = async (finished = false) => {
const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0;
const response = (await fs.readFile(headers, 'utf8').catch(() => ''))
.split(/(?=HTTP\/\S+ \d{3})/)
.at(-1);
const length = response?.match(/^content-length:\s*(\d+)\s*$/im);
const total =
expected?.size ||
(length && Number(length[1]) > 0 ? Number(length[1]) : null);
await onProgress({
phase: 'downloading',
artifact,
downloadedBytes: bytes,
totalBytes: total,
percent: total
? finished
? 100
: Math.min(99, Math.floor((bytes * 100) / total))
: null,
});
};
const timer = setInterval(() => {
sampling = sampling.then(() => sample());
// Observe failures immediately; propagate after the transfer has closed.
sampling.catch(() => {});
}, 1000);
try {
await run('curl', [
'--fail',
'--location',
...(quiet ? ['--silent', '--show-error'] : []),
'--proto',
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
String(
artifact === 'qinglong-bundle' && expected?.size
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
: 600,
),
'--speed-limit',
'1024',
'--speed-time',
'30',
'--dump-header',
headers,
'--output',
archive,
url,
]);
clearInterval(timer);
await sampling;
if (expected) {
const bytes = await fs.readFile(archive);
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
throw new Error('Upgrade archive size or checksum mismatch');
}
await sample(true);
} finally {
clearInterval(timer);
await sampling;
}
}
async function stageUpgrade({
root,
tmp,
env,
mirror,
fallbackMirror,
run,
install,
signal,
onProgress,
}) {
const currentPackage = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
return require('./platformBundle.cjs').stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic: verifyUpgrade,
download: downloadWithProgress,
});
}
const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
try {
// Metadata is optional for older releases and unavailable mirrors. Never
// invent a percentage when neither metadata nor HTTP supplies a total.
let info;
if (onProgress && mirror === 'github') {
try {
const readJson = async (url) => {
const filename = path.join(workspace, 'metadata.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'5',
'--max-time',
'10',
'--max-filesize',
'1048576',
'--dump-header',
`${filename}.headers`,
'--output',
filename,
url,
]);
return JSON.parse(await fs.readFile(filename, 'utf8'));
};
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
const current = await readJson(
`${base}/${branch}/build-info.json?t=${Date.now()}`,
);
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
throw new Error('Invalid commit');
const candidate = await readJson(
`${base}/upgrade-info/${current.sourceCommit}.json`,
);
if (
candidate.version !== 1 ||
candidate.sourceCommit !== current.sourceCommit ||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
!['qinglong-static', 'qinglong'].every((repo) => {
const entry = candidate.archives?.[repo];
return (
Number.isSafeInteger(entry?.size) &&
entry.size > 0 &&
/^[a-f0-9]{64}$/.test(entry.sha256)
);
})
)
throw new Error('Invalid archive metadata');
info = candidate;
} catch {
signal?.throwIfAborted();
}
}
async function extract(repository, ref) {
const archive = path.join(workspace, `${repository}.zip`);
const url =
mirror === 'github'
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
await run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--output',
archive,
url,
]);
const download = async (target, expected) => {
await report({
phase: 'downloading',
artifact: repository,
downloadedBytes: 0,
totalBytes: expected?.size || null,
percent: expected ? 0 : null,
});
await downloadWithProgress(
run,
archive,
target,
repository,
report,
Boolean(onProgress),
expected,
);
};
try {
await download(
url,
mirror === 'github' ? info?.archives[repository] : undefined,
);
} catch (error) {
signal?.throwIfAborted();
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
await download(
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
);
}
await report({
phase: 'extracting',
artifact: repository,
percent: null,
});
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
@@ -189,21 +404,30 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
name.split('/')[0] !== expected,
)
)
throw new Error('Invalid archive paths / 升级归档路径无效。');
throw new Error(upgradeMessage('升级归档路径无效。', env));
const modes = await run('unzip', ['-Z', '-l', archive], true);
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
throw new Error(upgradeMessage('升级归档包含符号链接。', env));
await run('unzip', ['-oq', archive, '-d', workspace]);
return path.join(workspace, expected);
}
// Static branches are mutable snapshots. Their manifest pins the exact
// source revision, avoiding a race between two independent branch reads.
const staticRoot = await extract('qinglong-static', branch);
const manifest = await manifestAt(staticRoot);
const downloadedStatic = await extract(
'qinglong-static',
info?.staticCommit || branch,
);
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
if (downloadedStatic !== staticRoot)
await fs.rename(downloadedStatic, staticRoot);
const manifest = await manifestAt(staticRoot, env);
if (info && manifest.sourceCommit !== info.sourceCommit)
throw new Error(upgradeMessage('构建清单无效。', env));
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await verifyUpgrade(source, staticRoot);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
@@ -220,9 +444,10 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
)
dependenciesChanged = true;
if (dependenciesChanged) {
await install(source, staticRoot, manifest.sourceCommit);
await report({ phase: 'dependencies', percent: null });
await install(source);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot);
await verifyUpgrade(source, staticRoot, env);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
@@ -230,6 +455,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged,
}),
@@ -269,7 +495,12 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
}
}
async function selectedUpgrade(tmp, branch) {
async function selectedUpgrade(
tmp,
branch,
env = process.env,
{ metadataOnly = false } = {},
) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -277,7 +508,7 @@ async function selectedUpgrade(tmp, branch) {
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
throw new Error(upgradeMessage('暂存升级指针无效。', env));
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
@@ -286,22 +517,48 @@ async function selectedUpgrade(tmp, branch) {
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error('Invalid staging directory / 暂存目录无效。');
throw new Error(upgradeMessage('暂存目录无效。', env));
}
const ready = JSON.parse(
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
);
if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
const manifest = await verifyUpgrade(selected.source, selected.static);
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = metadataOnly
? await manifestAt(selected.static, env)
: await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
else {
const root = ready.root || env.QL_DIR;
if (
!root ||
!(
await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)
)?.isDirectory()
)
throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env));
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
.readFile(path.join(root, name))
.catch(() => undefined);
if (
!current ||
!(await fs.readFile(path.join(selected.source, name))).equals(current)
)
throw new Error(
upgradeMessage('运行依赖已变化,请重新下载更新。', env),
);
}
}
return selected;
}
async function replaceAndReload(replacements, lifecycle) {
async function replaceAndReload(replacements, lifecycle, env = process.env) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
@@ -349,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle) {
} catch (error) {
// Do not modify files under a still-running failed startup. Keep backups
// for recovery if stopping the new process itself fails.
if (attemptedStart) await lifecycle.stop();
if (attemptedStart) await lifecycle.stop(true);
for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) {
@@ -373,7 +630,7 @@ async function replaceAndReload(replacements, lifecycle) {
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(`Retained backup / 保留备份: ${item.backup}`),
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
);
}
@@ -385,8 +642,9 @@ async function reloadSystem({
config,
env,
lifecycle,
additionalReplacements = [],
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env));
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relative = path.relative(root, data);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
@@ -404,7 +662,8 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'),
},
);
await replaceAndReload(replacements, lifecycle);
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env);
// Cleanup after a committed installation must not report a failed update.
await fs
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
@@ -416,7 +675,16 @@ async function reloadSystem({
.catch(() => {});
}
async function waitForHealth(env, timeout = 60000, signal) {
function startupHealthTimeout(runtime = process) {
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
// slow devices time to become healthy before deciding to restore a backup.
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6
? 300000
: 60000;
}
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
const port = Number(env.QlPort || 5700);
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
@@ -472,7 +740,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
}
}
}
throw new Error('Backend failed health check / 后端启动检查失败。');
throw new Error(upgradeMessage('后端启动检查失败。', env));
}
function runProcess(env, program, args, capture = false, signal) {
@@ -553,13 +821,7 @@ async function withLifecycleHooks(root, tmp, env, operation) {
case "$1" in
stop) delete_pm2 ;;
start) reload_pm2 ;;
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"
;;
install) npm_install_2 "$2"; exit "$exit_status" ;;
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
esac
`,
@@ -568,9 +830,18 @@ esac
mode: 0o600,
},
);
// Offline selection and worker ownership belong to this operation only.
// Passing them to PM2 would pin future online updates to the old local file.
const lifecycleEnv = { ...env };
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete lifecycleEnv[name];
const hook = (operation, args = [], signal) =>
runProcess(
env,
lifecycleEnv,
'bash',
['--', snapshot, operation, ...args],
false,
@@ -581,9 +852,7 @@ esac
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
),
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
);
}
}
@@ -601,50 +870,95 @@ async function main() {
);
const run = (program, args, capture) =>
runProcess(env, program, args, capture, controller.signal);
await withUpgradeLock(tmp, () =>
withLifecycleHooks(root, tmp, env, async (hook) => {
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
mirror: process.argv[3],
run,
await withUpgradeLock(
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
let job;
const lifecycle = {
signal: controller.signal,
install: (source, 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('更新及启动检查成功...');
}),
stop: async () => {
await job?.onStop();
await hook('stop');
},
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
undefined,
recover ? undefined : controller.signal,
);
await job?.onStart(recover);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
mirror: process.argv[3],
// The Bash updater chooses its source automatically. An explicitly
// selected CLI mirror does not opt into this network-only failover.
fallbackMirror: 'gitee',
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
});
console.log(upgradeMessage('更新包下载及校验成功...', env));
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
const apply = async (preparedJob) => {
job = preparedJob;
await reloadSystem({
root,
tmp,
env,
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
};
// Legacy reloads can carry only this self-contained Bash verifier.
// Complete-bundle installs also track manual applies without a job file.
const hasPanelJob = await fs
.access(path.join(tmp, 'panel-upgrade.json'))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
const packageInfo = hasPanelJob
? {}
: JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
metadataOnly: true,
});
await require('./panelUpgrade.cjs').withPreparedJob(
tmp,
env,
selected,
apply,
);
} else await apply();
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
);
}
module.exports = {
downloadWithProgress,
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
@@ -652,6 +966,7 @@ module.exports = {
selectedUpgrade,
replaceAndReload,
reloadSystem,
startupHealthTimeout,
waitForHealth,
runProcess,
withLifecycleHooks,
@@ -659,6 +974,6 @@ module.exports = {
};
if (require.main === module)
main().catch((error) => {
console.error(`更新失败: ${error.message}`);
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
process.exitCode = 1;
});
@@ -0,0 +1,396 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const os = require('node:os');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
function probeLimits(target) {
const small = target.arch === 'armv6';
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
}
function dependencyFailure(error) {
const text = `${error.message || ''}\n${error.stack || ''}\n${
error.stderr || ''
}`;
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
return false;
if (
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
/node_modules[/\\]/.test(text)
)
return true;
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
text,
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
const inDependencies = (filename) =>
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
// Node reports a missing package entry as an absolute path and missing
// package-internal imports as relative paths. Neither is an app source error.
const dependencyMissing =
missing &&
((!missing.startsWith('.') &&
!path.isAbsolute(missing) &&
!missing.startsWith('node:')) ||
inDependencies(missing) ||
inDependencies(importer));
// Missing backend files, bad config and database errors must never trigger npm.
return (
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
Boolean(dependencyMissing)) ||
(/Could not locate the bindings file/.test(text) &&
/node_modules[/\\]/.test(text))
);
}
async function prestartBackend(
selected,
{ target, signal, limits = probeLimits(target) },
) {
signal?.throwIfAborted();
const workspace = await fs.mkdtemp(
path.join(path.dirname(selected.source), 'prestart-'),
);
try {
const data = path.join(workspace, 'data');
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
await fs.mkdir(path.join(data, name), { recursive: true });
// Copy only backend code/metadata, never all frontend assets or node_modules.
await fs.mkdir(path.join(workspace, 'static'));
await fs.cp(
path.join(selected.static, 'build'),
path.join(workspace, 'static/build'),
{ recursive: true },
);
await fs.copyFile(
path.join(selected.static, 'build-info.json'),
path.join(workspace, 'static/build-info.json'),
);
await fs.copyFile(
path.join(selected.source, 'version.yaml'),
path.join(workspace, 'version.yaml'),
);
await fs.symlink(
path.join(selected.source, 'node_modules'),
path.join(workspace, 'node_modules'),
'dir',
);
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
const entry = path.join(
workspace,
'static/build/bootstrap/upgradeProbe.js',
);
await fs.access(entry);
const expected = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const backendSha256 = crypto
.createHash('sha256')
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
.digest('hex');
const version = (
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
).match(
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
// package-manager settings from escaping into the disposable backend.
const env = {
PATH: process.env.PATH,
LANG: process.env.LANG || 'C.UTF-8',
HOME: workspace,
TMPDIR: workspace,
NODE_ENV: 'production',
QL_DIR: workspace,
QL_DATA_DIR: data,
QL_UPGRADE_PROBE: '1',
BACK_PORT: '0',
GRPC_PORT: '0',
BIND_HOST: '127.0.0.1',
BIND_HOST_GRPC: '127.0.0.1',
QlBaseUrl: '/',
NODE_PATH: '',
NODE_OPTIONS: '',
UV_THREADPOOL_SIZE: '1',
};
return await new Promise((resolve, reject) => {
let report,
failure,
output = '',
finished = false,
killTimer;
// No PM2/cluster workers. OpenSSL key generation is sequential in this
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
const child = spawn(
process.execPath,
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
{
cwd: workspace,
env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
},
);
try {
if (child.pid) os.setPriority(child.pid, 10);
} catch {}
const kill = (signalName) => {
try {
if (process.platform !== 'win32' && child.pid)
process.kill(-child.pid, signalName);
else child.kill(signalName);
} catch (error) {
if (error.code !== 'ESRCH') failure ||= error;
}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(signal.reason || new Error('Upgrade prestart aborted'));
const timer = setTimeout(
() => stop(new Error('Upgrade prestart timed out')),
limits.timeoutMs,
);
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.stderr.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.on('message', (message) => {
if (message?.type === 'upgrade-probe') report = message;
});
const finish = (error) => {
if (finished) return;
finished = true;
clearTimeout(timer);
clearTimeout(killTimer);
signal?.removeEventListener('abort', abort);
if (error) reject(error);
else resolve(report);
};
child.on('error', (error) => finish(error));
// Descendants can retain our output pipes after their parent exits.
// Reap the group on exit rather than waiting for those pipes to close.
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, exitSignal) => {
// Reap descendants even when the backend exits unexpectedly.
kill('SIGKILL');
if (failure) return finish(failure);
if (code !== 0 || !report?.ok)
return finish(
Object.assign(
new Error(
report?.message ||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
),
{
code: report?.code,
stack: report?.stack || output,
signal: exitSignal,
},
),
);
if (
report.build?.sourceCommit !== expected.sourceCommit ||
report.build?.backendSha256 !== backendSha256 ||
report.build?.version !== version
)
return finish(new Error('Upgrade prestart build identity mismatch'));
finish();
});
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
async function repairDependencies(source, { target, signal, env }) {
const workspace = await fs.mkdtemp(
path.join(path.dirname(source), 'repair-'),
);
try {
const pkg = JSON.parse(
await fs.readFile(path.join(source, 'package.json')),
);
if (pkg.packageManager !== 'pnpm@8.3.1')
throw new Error('Unsupported frozen-lockfile package manager');
// Install apart from the verified application: lifecycle scripts cannot
// rewrite source/static files or use the old installation's node_modules.
await fs.writeFile(
path.join(workspace, 'package.json'),
JSON.stringify({ ...pkg, scripts: {} }),
);
await fs.copyFile(
path.join(source, 'pnpm-lock.yaml'),
path.join(workspace, 'pnpm-lock.yaml'),
);
const small = target.arch === 'armv6';
const childEnv = {
PATH: env.PATH || process.env.PATH,
HOME: workspace,
TMPDIR: workspace,
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
small ? 256 : 384
}`,
NODE_PATH: '',
CI: 'true',
npm_config_jobs: '1',
MAKEFLAGS: '-j1',
...(small
? {
npm_config_build_from_source: 'true',
npm_config_force_process_config: 'true',
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
}
: {}),
};
// Preserve network routing without inheriting Node/preload/compiler settings.
for (const name of [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
])
if (typeof env[name] === 'string') childEnv[name] = env[name];
// Reuse matching installed headers on small devices; never inherit a
// caller's nodedir or accidentally compile against a different Node ABI.
const prefix = path.resolve(path.dirname(process.execPath), '..');
const header = await fs
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
.catch(() => '');
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
.map(
(part) =>
header.match(
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
)?.[1],
)
.join('.');
if (headerVersion === process.versions.node) {
childEnv.npm_config_nodedir = prefix;
childEnv.npm_config_force_process_config = 'true';
}
const run = (args) =>
runRepair('pnpm', args, {
cwd: workspace,
env: childEnv,
signal,
timeoutMs: small ? 1800000 : 600000,
});
const version = await run(['--version']);
if (version.trim() !== '8.3.1')
throw new Error('Dependency repair requires existing pnpm 8.3.1');
await run([
'install',
'--prod',
'--frozen-lockfile',
'--ignore-scripts',
'--child-concurrency=1',
'--network-concurrency=2',
]);
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
if (
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
)
)
throw new Error('Dependency repair changed the frozen lockfile');
await fs.rm(path.join(source, 'node_modules'), {
recursive: true,
force: true,
});
await fs.rename(
path.join(workspace, 'node_modules'),
path.join(source, 'node_modules'),
);
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
// Bound output, wall time and the entire npm/node-gyp process group.
async function runRepair(program, args, options) {
return new Promise((resolve, reject) => {
options.signal?.throwIfAborted();
let output = '',
stdout = '',
failure,
killTimer;
const child = spawn(program, args, {
cwd: options.cwd,
env: options.env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kill = (sig) => {
try {
if (child.pid && process.platform !== 'win32')
process.kill(-child.pid, sig);
else child.kill(sig);
} catch {}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(options.signal.reason || new Error('Dependency repair aborted'));
const timer = setTimeout(
() => stop(new Error('Dependency repair timed out')),
options.timeoutMs,
);
const capture = (chunk) => {
output = (output + chunk).slice(-32768);
};
child.stdout.on('data', (chunk) => {
stdout = (stdout + chunk).slice(-32768);
capture(chunk);
});
child.stderr.on('data', capture);
const cleanup = () => {
clearTimeout(timer);
clearTimeout(killTimer);
options.signal?.removeEventListener('abort', abort);
kill('SIGKILL');
};
child.on('error', (error) => {
cleanup();
reject(error);
});
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, signal) => {
cleanup();
if (failure || code !== 0)
reject(
failure ||
new Error(
`Dependency repair failed (${signal || code}): ${output}`,
),
);
else resolve(stdout);
});
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
});
}
module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
probeLimits,
};
+14 -4
View File
@@ -1,5 +1,8 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const { armv6ServerArgs } = require('../maintenance/nodeRuntime.cjs') as {
armv6ServerArgs: readonly string[];
};
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
// each process's cwd, not from the operator's cwd or a broad ps substring match.
@@ -27,11 +30,18 @@ export async function findDirectBackendPids(
);
const args = command.split('\0');
if (args.at(-1) === '') args.pop();
// Both the legacy fallback and the TS fallback launch one script without
// Node flags or application arguments. Reject other command shapes.
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
// Accept only the two launch shapes we own. Never treat eval, preload or
// application arguments containing this path as a running backend.
const scriptIndex =
args.length === 2
? 1
: args.length === armv6ServerArgs.length + 2 &&
armv6ServerArgs.every((flag, i) => args[i + 1] === flag)
? args.length - 1
: -1;
if (scriptIndex < 0 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
continue;
const script = args[1]!;
const script = args[scriptIndex]!;
if (!script) continue;
const absolute = path.isAbsolute(script)
? script
@@ -214,8 +214,13 @@ async function reconcile(
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
title: translate(context.env, '%s 订阅同步', namespace),
content: translate(
context.env,
'新增 %s 个任务,删除 %s 个任务',
added,
removed,
),
});
} catch {
operationOutput(
+32 -1
View File
@@ -5,6 +5,7 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -12,6 +13,12 @@ export interface OpenOperation {
anonymous?: boolean;
}
export const openOperations: readonly OpenOperation[] = [
{
name: 'system notification-logs',
method: 'GET',
path: 'system/notify-log',
},
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
{
name: 'task view-create',
@@ -328,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
@@ -410,6 +435,7 @@ export const openOperations: readonly OpenOperation[] = [
body: 'json',
},
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
{ name: 'system update-status', method: 'GET', path: 'system/update-status' },
{ name: 'system update', method: 'PUT', path: 'system/update' },
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
@@ -478,7 +504,12 @@ export const openOperations: readonly OpenOperation[] = [
path: 'system/config/dependence-clean',
body: 'json',
},
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
{
name: 'dashboard record',
method: 'POST',
path: 'dashboard/record',
body: 'json',
},
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
+15 -7
View File
@@ -17,20 +17,28 @@ export function configPath(): string {
export function readConfig(): StoredConfig {
let fd: number | undefined;
try {
fd = fs.openSync(
configPath(),
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
const file = configPath();
const pathStat = fs.lstatSync(file);
if (!pathStat.isFile())
fail(
translate(
process.env,
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
const stat = fs.fstatSync(fd);
if (
!stat.isFile() ||
stat.mode & 0o077 ||
(process.getuid && stat.uid !== process.getuid())
pathStat.dev !== stat.dev ||
pathStat.ino !== stat.ino
) {
fail(
translate(
process.env,
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
}
+8
View File
@@ -107,6 +107,14 @@ export async function auth(
{
crons: 'crons?page=1&size=1',
configs: 'configs/files',
'envs:read': 'envs',
'configs:read': 'configs/files',
'scripts:read': 'scripts',
'crons:read': 'crons?page=1&size=1',
'subscriptions:read': 'subscriptions',
'dependencies:read': 'dependencies',
'logs:read': 'logs',
'dashboard:read': 'dashboard/overview',
dashboard: 'dashboard/overview',
} as Record<string, string>
)[scope] ?? scope,
+15 -3
View File
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
).test(path),
);
if (!match)
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
return match;
}
@@ -107,7 +115,11 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+8
View File
@@ -26,6 +26,14 @@ export const commands: readonly CommandSpec[] = [
default: 'crons',
choices: [
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
'envs:read',
'configs:read',
'scripts:read',
'crons:read',
'subscriptions:read',
'dependencies:read',
'logs:read',
'dashboard:read',
].filter((scope) => !['auth', 'update'].includes(scope)),
},
},
+17 -10
View File
@@ -1,8 +1,13 @@
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
export const english: Record<string, string> = {
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
'%s 订阅同步': '%s subscription sync',
'新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks',
'需要双因素验证,请调用 user two-factor-login。':
'Two-factor authentication required; use user two-factor-login.',
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。':
'Provide QL_URL and QL_ACCESS_TOKEN together.',
'接口返回 JSON,未保存下载文件。':
'The endpoint returned JSON; no download was saved.',
'已选择的 CLI 安装路径必须为绝对路径。':
'The selected CLI installation path must be absolute.',
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
@@ -152,9 +157,12 @@ export const english: Record<string, string> = {
'应用凭据和 %s 权限': 'application credentials and %s permission',
未知: 'unknown',
'请检查%s。': 'Check %s.',
'资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.',
'服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.',
'请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.',
'资源冲突,请检查重复的名称或值。':
'Resource conflict; check for duplicate names or values.',
'服务端错误,请检查服务状态和日志。':
'Server error; check service status and logs.',
'请求未成功,请检查请求参数和服务日志。':
'Request unsuccessful; check request parameters and service logs.',
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
' 执行结果可能未知,请先检查%s状态再考虑重试。':
@@ -167,10 +175,9 @@ export const english: Record<string, string> = {
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
'API 请求被拒绝(code %s),请检查%s。':
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置无效,请运行 ql login。':
'Invalid CLI config. Run ql login.',
'CLI 配置必须是普通文件,且不能是符号链接。':
'CLI config must be a regular file, not a symbolic link.',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
'Cannot read CLI config. Check file permissions and run ql login.',
+183
View File
@@ -0,0 +1,183 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
// Change only runtime selection; execute the compiled operator, actual child
// Node and process discovery, without pretending the host CPU is ARM.
async function operatorFor(arch, armVersion, node) {
const filename = require.resolve('../../dist/internal/maintenance/operator');
const localRequire = createRequire(filename);
const module = { exports: {} };
const flags = runtime.serverRuntimeArgs({
arch,
config: { variables: { arm_version: armVersion } },
versions: { node },
});
vm.runInNewContext(
await fs.readFile(filename, 'utf8'),
{
module,
exports: module.exports,
process,
setTimeout,
clearTimeout,
require(name) {
return name === './nodeRuntime.cjs'
? { ...runtime, serverRuntimeArgs: () => [...flags] }
: localRequire(name);
},
},
{ filename },
);
return { ...module.exports, flags };
}
for (const [arch, arm, node] of [
['arm', 6, '24.18.1'],
['arm', 6, '20.18.1'],
['arm', 6, '22.20.0'],
['arm', 7, '24.18.1'],
['arm64', undefined, '24.18.1'],
['x64', undefined, '24.18.1'],
])
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
const operator = await operatorFor(arch, arm, node);
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
);
const context = createContext(
{ root },
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
);
const entry = path.join(context.paths.dir_static, 'build/app.js');
let pid;
t.after(async () => {
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
`require('fs').writeFileSync(${JSON.stringify(
path.join(root, 'runtime.json'),
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
);
const started = await operator.startPanel(context);
pid = started.pid;
const report = JSON.parse(
await fs.readFile(path.join(root, 'runtime.json')),
);
assert.equal(started.manager, 'node');
assert.deepEqual(report.args, operator.flags);
assert.equal(report.pid, pid);
await operator.stopPanel(context);
for (let n = 0; n < 100; n++) {
try {
process.kill(pid, 0);
} catch (e) {
if (e.code === 'ESRCH') {
pid = undefined;
break;
}
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(
pid,
undefined,
'the exact backend must stop even with Node flags',
);
});
test(
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
{
skip: process.platform !== 'linux',
timeout: 20000,
},
async (t) => {
const operator = await operatorFor('arm', 6, '24.18.1');
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
);
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
const entry = path.join(context.paths.dir_static, 'build/app.js');
const incoming = path.join(root, 'incoming.js'),
journal = path.join(root, 'journal.jsonl');
const record = (version) =>
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({version:${JSON.stringify(
version,
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.mkdir(context.data, { recursive: true });
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
mode: 0o600,
});
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
await fs.writeFile(entry, record('old'));
await fs.writeFile(incoming, record('new'));
const pids = [];
t.after(async () => {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
pids.push((await operator.startPanel(context)).pid);
await assert.rejects(
replaceAndReload(context, [{ source: incoming, target: entry }], {
stop: (ctx) => operator.stopPanel(ctx),
start: async (ctx, recover) => {
const result = await operator.startPanel(ctx);
pids.push(result.pid);
if (!recover) throw new Error('injected new-version health failure');
},
}),
/injected new-version health failure/,
);
const launches = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
launches.map((x) => x.version),
['old', 'new', 'old'],
);
for (const row of launches) assert.deepEqual(row.args, operator.flags);
for (const row of launches.slice(0, -1))
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
process.kill(launches.at(-1).pid, 0);
assert.equal(
await fs.readFile(path.join(root, '.env'), 'utf8'),
'KEEP=custom\n',
);
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
assert.equal(
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
'old data',
);
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
assert.ok(
!(await fs.readdir(path.dirname(entry))).some((x) =>
x.includes('ql-backup'),
),
);
await operator.stopPanel(context);
},
);
+47 -3
View File
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
const {
findDirectBackendPids,
} = require('../../dist/internal/runtime/backendProcesses');
test('backend discovery accepts only owned compatibility flags and the exact installation entry', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-flagged-proc-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const proc = path.join(root, 'proc'),
panel = path.join(root, 'panel with spaces');
const other = path.join(root, 'other/static/build/app.js');
const entry = path.join(panel, 'static/build/app.js');
for (const file of [entry, other]) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, '');
}
const flags = ['--no-opt', '--no-sparkplug', '--no-maglev'];
const cases = [
[['/usr/bin/node', ...flags, entry], true],
[['node24', ...flags, 'static/build/app.js'], true],
[['node', ...flags, other], false],
[['node', '-e', entry], false],
[['node', '-r', entry], false],
[['node', ...flags, entry, 'argument'], false],
[['node', '--no-opt', entry], false],
[['bash', ...flags, entry], false],
[['node', ...flags, '-e', entry], false],
];
const expected = [];
for (const [i, [args, matches]] of cases.entries()) {
const pid = 910001 + i,
directory = path.join(proc, String(pid));
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
await fs.symlink(panel, path.join(directory, 'cwd'));
if (matches) expected.push(pid);
}
assert.deepEqual((await findDirectBackendPids(entry, proc)).sort(), expected);
});
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
const root = await fs.realpath(
@@ -64,7 +103,10 @@ test(
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { createContext } = require('../../dist/internal/runtime/context');
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
const {
stopPanel,
startPanel,
} = require('../../dist/internal/maintenance/operator');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
const children = [];
let fallbackPid;
@@ -223,7 +265,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const { createContext } = require('../../dist/internal/runtime/context');
const { startPanel } = require('../../dist/internal/maintenance/operator');
const {
startPanel,
} = require('../../dist/internal/maintenance/operator');
const context = createContext({ root }, { PATH: '', QL_LANG: language });
const entry = path.join(context.paths.dir_static, 'build/app.js');
await fs.mkdir(path.dirname(entry), { recursive: true });
+6 -76
View File
@@ -46,7 +46,6 @@ 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);
`;
@@ -65,11 +64,6 @@ 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 = {
@@ -88,7 +82,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, base, registrations } = await fixture(t);
const { ctx, system, hooks, 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');
@@ -113,7 +107,6 @@ 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',
@@ -125,23 +118,14 @@ test('bootstrap installs prerequisites, uses the configured data directory and s
'pm2',
],
);
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, [
assert.deepEqual(calls[4].args, [
'install',
'--prefix',
path.join(ctx.data, 'dep_cache/python3'),
'requests',
]);
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig, '-s', 'reload']);
assert.deepEqual(calls[8].args, ['-c', system.nginxConfig]);
assert.deepEqual(calls[6].args, ['-c', system.nginxConfig, '-s', 'reload']);
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig]);
assert.deepEqual(
calls.slice(-4).map((c) => c.args),
[
@@ -153,7 +137,7 @@ test('bootstrap installs prerequisites, uses the configured data directory and s
);
assert.ok(
calls
.slice(5)
.slice(4)
.every(
(c) =>
c.python === path.join(ctx.data, 'dep_cache/python3') &&
@@ -256,9 +240,7 @@ 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) => {
@@ -273,55 +255,3 @@ 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'),
),
);
});
+11 -2
View File
@@ -322,6 +322,10 @@ test(
async (t) => {
if (process.platform === 'win32') return t.skip('POSIX process groups');
const { runProcess } = require('../../dist/internal/runtime/process');
const marker = path.join(sandbox(t), 'timeout');
const controller = new AbortController();
const guard = setTimeout(() => controller.abort(), 4000);
t.after(() => clearTimeout(guard));
let descendant;
t.after(() => {
if (descendant)
@@ -329,13 +333,17 @@ test(
process.kill(descendant, 'SIGKILL');
} catch {}
});
// Start the deadline only after the descendant installed its handlers.
// Cold Node startup under parallel load need not fit in 350 milliseconds.
const grandchild =
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)';
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild,
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`;
)}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
const result = await runProcess(process.execPath, ['-e', parent], {
timeoutMs: 350,
timeoutControl: { marker },
signal: controller.signal,
graceMs: 100,
output: (chunk) => {
const pid = Number(chunk.toString().trim());
@@ -345,6 +353,7 @@ test(
assert.equal(result.code, 124);
assert.equal(result.timedOut, true);
assert.ok(descendant);
assert.equal(fs.existsSync(marker), true);
},
);
+59 -229
View File
@@ -20,6 +20,40 @@ const {
sourceCommit,
} = require('../../../test/helpers/upgrade-fixture.cjs');
test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl');
ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz';
ctx.env.QL_UPGRADE_REPAIR_DEPS = '1';
ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345';
ctx.env.QA_USER_SETTING = 'preserved';
ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890';
await stub(
ctx,
'pm2',
`require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({
file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null,
launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING,
proxy:process.env.HTTPS_PROXY,
})+'\\n');`,
);
await startPanel(ctx);
for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse))
assert.deepEqual(runtime, {
file: null,
repair: null,
launcher: null,
user: 'preserved',
proxy: 'http://127.0.0.1:7890',
});
assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz');
assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1');
assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345');
});
async function fixture(t) {
t.mock.method(
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
@@ -59,24 +93,6 @@ 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 });
@@ -93,51 +109,29 @@ test('configuration repair preserves custom content and intentionally empty hook
assert.deepEqual(await repairConfiguration(ctx), [ctx.paths.file_notify_py]);
});
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) => {
test('dependency installation selects pnpm or Termux npm argv without shell interpolation', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'dependencies.jsonl');
const output = path.join(ctx.root, 'calls.jsonl');
ctx.env.CALLS = output;
const 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);
'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);
await installPanelDependencies(ctx);
ctx.env.is_termux = '1';
await installPanelDependencies(ctx);
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'))
const calls = (await fs.readFile(output, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
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',
});
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));
});
test('PM2 reload uses installation cwd and exact process-manager arguments', async (t) => {
@@ -316,50 +310,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
'utf8',
),
),
{ ...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,
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
);
await fs.symlink(
'../../outside',
@@ -370,11 +321,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(changedStage.source)),
path.basename(path.dirname(staged.source)),
'upgrade-ready-master.json',
].sort(),
);
@@ -419,9 +370,8 @@ 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 ['pnpm', 'pm2']) await stub(ctx, executable, record);
const toolsPrefix = await stubRuntimeTools(ctx, record);
await stubPanelDependencies(ctx, record);
for (const executable of ['npm', 'pnpm', 'pm2'])
await stub(ctx, executable, 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 });
@@ -448,22 +398,11 @@ test('check repairs dependencies and notifications, probes loopback and reloads
assert.deepEqual(calls, [
{
program: 'npm',
args: ['prefix', '--global'],
args: ['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
},
{
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: 'pnpm',
args: ['install', '--loglevel', 'error', '--production'],
},
{ program: 'pm2', args: ['flush'] },
{
@@ -532,28 +471,6 @@ 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);
@@ -561,8 +478,7 @@ 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"; printf '%s\\n' "$dir_root/global-prefix"; }
node() { printf 'tools %s\\n' "$*" >> "$TRACE"; }
npm() { printf 'npm %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 "$@"; }
@@ -586,8 +502,7 @@ 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 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`,
'npm i -g pnpm@8.3.1 pm2 ts-node typescript@5',
'repair-config',
`dependencies ${ctx.root}`,
`copy -fv ${ctx.paths.file_notify_py_sample} ${ctx.paths.file_notify_py}`,
@@ -604,91 +519,6 @@ 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
@@ -0,0 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
@@ -0,0 +1,99 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
const { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const status of ['ready', 'failed', 'completed', 'absent'])
for (const wrongVersion of [false, true])
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (status === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status,
error: 'old error',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
}),
);
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => ({
manager: 'fixture',
}));
t.mock.method(artifacts, 'waitForHealth', async () => {});
const operation = reloadPanel(
createContext({ root: f.root }, f.env),
'system',
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.error, 'old error');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
for (const wrongVersion of [false, true]) {
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const context = createContext({ root: f.root }, f.env);
const timeouts = [];
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
timeouts.push(timeout);
});
t.mock.method(operator, 'stopPanel', async () => {
assert.equal((await f.state()).status, 'restarting');
});
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
const operation = reloadPanel(context, 'system');
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
assert.ok(timeouts.length > 0);
assert.ok(
timeouts.every((timeout) => timeout === undefined),
'use the shared ARMv6/default startup deadline',
);
assert.equal(
(await f.state()).status,
wrongVersion ? 'failed' : 'completed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
});
}
+1 -1
View File
@@ -23,7 +23,7 @@ test(
assert.notEqual(before.dev, (await fs.stat(root)).dev);
const context = createContext(
{ root, 'data-dir': data },
{ PATH: '/nonexistent' },
{ PATH: '/nonexistent', QL_LANG: 'en' },
);
t.after(async () => {
await stopPanel(context);
+4 -4
View File
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
return JSON.parse(result.stdout).data;
};
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard', '--show-secrets'], true);
assert.ok(app.client_id && app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
cli(['auth', 'status', '--scope', 'crons']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard'], true);
assert.ok(cli(['app', 'list'], true).every(item => !('client_secret' in item) && !('tokens' in item)));
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
+2 -13
View File
@@ -2,9 +2,7 @@ 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 () => {
@@ -67,16 +65,7 @@ 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);
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,
);
await installPanelDependencies(context, '/stage/source');
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, '../preload.test.cjs'),
path.resolve(__dirname, '../internal/preload.test.cjs'),
],
{
env,
+9 -1
View File
@@ -43,6 +43,7 @@ test(
context.paths.dir_tmp,
);
installations.push(directory);
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
},
);
const controller = new AbortController();
@@ -70,9 +71,16 @@ test(
),
);
assert.equal(pointer.directory, path.basename(workspace));
const manifest = JSON.parse(
await fs.readFile(path.join(staged.static, 'build-info.json'), 'utf8'),
);
assert.deepEqual(
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
staged,
{
...staged,
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
},
);
assert.equal(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
+88 -3
View File
@@ -81,6 +81,13 @@ async function fixture(t) {
code: 200,
data: { data: [{ id: 12, name: 'example' }], total: 1 },
});
else if (
['/panel/open/envs', '/panel/open/configs/files', '/panel/open/scripts',
'/panel/open/subscriptions', '/panel/open/dependencies',
'/panel/open/logs', '/panel/open/dashboard/overview']
.includes(url.pathname)
)
json({ code: 200, data: [] });
else if (
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
url.pathname,
@@ -155,6 +162,21 @@ test('compiled entry: login aliases, private storage, status, refresh and local
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
assert.doesNotMatch(status.out, /test-secret|test-token/);
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
for (const [scope, pathname] of [
['envs:read', '/panel/open/envs'],
['configs:read', '/panel/open/configs/files'],
['scripts:read', '/panel/open/scripts'],
['crons:read', '/panel/open/crons'],
['subscriptions:read', '/panel/open/subscriptions'],
['dependencies:read', '/panel/open/dependencies'],
['logs:read', '/panel/open/logs'],
['dashboard:read', '/panel/open/dashboard/overview'],
]) {
const checked = await f.invoke(['auth', 'status', '--scope', scope, '--json']);
assert.equal(checked.code, 0, checked.err);
assert.equal(JSON.parse(checked.out).data.scopeChecked, scope);
assert.equal(f.requests.at(-1).url.pathname, pathname);
}
const config = JSON.parse(fs.readFileSync(f.file));
config.expiration = 1;
fs.writeFileSync(f.file, JSON.stringify(config));
@@ -296,11 +318,13 @@ test('malformed successful responses are rejected, including expired authenticat
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
});
test('config permissions, malformed content and symlinks fail closed', async (t) => {
test('config content and symlinks are checked independently of permission bits', async (t) => {
const f = await fixture(t);
await f.login();
fs.chmodSync(f.file, 0o644);
failure(await f.invoke(['task', 'list', '--json']), 1);
const sharedMode = await f.invoke(['task', 'list', '--json']);
assert.equal(sharedMode.code, 0, sharedMode.err);
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
fs.chmodSync(f.file, 0o600);
fs.writeFileSync(f.file, '{broken');
failure(await f.invoke(['task', 'list', '--json']), 1);
@@ -312,12 +336,73 @@ test('config permissions, malformed content and symlinks fail closed', async (t)
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json']), 1);
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
failure(linked, 1);
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
await f.login();
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
});
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'windows-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
const originalOpenSync = fs.openSync;
fs.openSync = function (file, flags, ...args) {
return originalOpenSync(file, typeof flags === 'number'
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
};
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json'], env), 1);
});
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'arm-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
stat.uid = 999999;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
});
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
const f = await fixture(t);
const dist = path.join(f.dir, 'standalone');
+24 -2
View File
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
for (const _ of op.params ?? []) args.push('7');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}
+74
View File
@@ -0,0 +1,74 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
test('saved login works on the host filesystem', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
const config = path.join(dir, 'cli.json');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
res.setHeader('content-type', 'application/json');
if (url.pathname === '/panel/open/auth/token') {
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
res.end(JSON.stringify({
code: 200,
data: {
token: 'smoke-token',
expiration: Math.floor(Date.now() / 1000) + 3600,
},
}));
return;
}
assert.equal(req.headers.authorization, 'Bearer smoke-token');
assert.equal(url.pathname, '/panel/open/crons');
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const env = {
...process.env,
QL_CLI_CONFIG: config,
QL_CLIENT_ID: 'smoke-id',
QL_CLIENT_SECRET: 'smoke-secret',
QL_URL: '',
QL_ACCESS_TOKEN: '',
};
const invoke = (args) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, [entry, ...args], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (data) => (out += data));
child.stderr.on('data', (data) => (err += data));
child.once('error', reject);
child.once('close', (code) => resolve({ code, out, err }));
});
const url = `http://127.0.0.1:${server.address().port}/panel`;
const login = await invoke(['login', '--url', url, '--json']);
assert.equal(login.code, 0, login.err);
assert.equal(JSON.parse(login.out).data.authenticated, true);
assert.equal(fs.lstatSync(config).isFile(), true);
const status = await invoke(['auth', 'status', '--json']);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.authenticated, true);
const list = await invoke(['task', 'list', '--json']);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 0);
});
+31 -9
View File
@@ -1,17 +1,16 @@
# 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:22.23.3-alpine3.24 AS builder
FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
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 \
&& 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 \
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& cd /tmp/build \
&& case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
@@ -19,12 +18,24 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
arm/v6) NODE_LIBC=musl-armv6 ;; \
arm/v7) NODE_LIBC=musl-armv7 ;; \
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
*) NODE_LIBC=musl ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile --side-effects-cache=false
FROM python:3.11.17-alpine3.24@sha256:d9368b3a5ac59afea7b5d4f2e2aea0941dbf9fdee9c369c5bec00b98244bc929
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.11-alpine3.24
ARG TARGETARCH
ARG TARGETVARIANT
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
@@ -43,10 +54,11 @@ VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /opt/node-global/lib/. /usr/local/lib/
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
@@ -63,6 +75,12 @@ RUN set -x \
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 \
@@ -87,6 +105,10 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \

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