mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f22f8d44 | ||
|
|
f2c2f5e4f8 | ||
|
|
2efc5e4503 | ||
|
|
b92621bdb8 | ||
|
|
da9c6a6bbb | ||
|
|
d895fbba36 | ||
|
|
6862ad4f6c | ||
|
|
b8110c52eb | ||
|
|
99a4d0eff8 | ||
|
|
b486989399 | ||
|
|
77fe551425 | ||
|
|
2669f251ac | ||
|
|
3f02b04dff | ||
|
|
e8b6a30bd9 | ||
|
|
a36a77086a | ||
|
|
cb5dfec2b5 | ||
|
|
286088a32c | ||
|
|
a1104ef47c | ||
|
|
1a152575cc | ||
|
|
ef348b445b | ||
|
|
8305825ffd | ||
|
|
e555fcbb0b | ||
|
|
83e8f6bccd | ||
|
|
5801f735bc | ||
|
|
53e1129a12 | ||
|
|
13c0a4d21b | ||
|
|
126fe8eb67 | ||
|
|
73c41eed22 | ||
|
|
40149b6f17 | ||
|
|
6532775a25 | ||
|
|
6469792546 | ||
|
|
0aa521a288 | ||
|
|
1ac04a8062 | ||
|
|
e435f6e886 | ||
|
|
db88d037db | ||
|
|
a0c09046c6 | ||
|
|
5a9e53c771 | ||
|
|
be877cfddc | ||
|
|
fdeffebba8 | ||
|
|
3c61e933a1 | ||
|
|
cf236f8324 | ||
|
|
7334566d3c | ||
|
|
4cd6c6ad2e | ||
|
|
81a60254a2 | ||
|
|
1503f07c75 | ||
|
|
70636900b2 | ||
|
|
331f4f9138 | ||
|
|
b06cb72334 | ||
|
|
89c6355635 | ||
|
|
68ffa106f8 | ||
|
|
9d5f58ef99 | ||
|
|
ef1c54a620 | ||
|
|
0218404d9f | ||
|
|
24f7d1a46f | ||
|
|
fa8910b3d6 | ||
|
|
2cab112391 | ||
|
|
2ab9c702e6 | ||
|
|
6dd98a8565 | ||
|
|
06c8d8b112 | ||
|
|
a5406bedca | ||
|
|
3d2b98a442 | ||
|
|
93d7ec69dc | ||
|
|
a303e33b4e | ||
|
|
44129ca088 | ||
|
|
def4917447 | ||
|
|
bc0f35e3f7 | ||
|
|
f168efcdae | ||
|
|
0af546cd1d | ||
|
|
0d33d2321a | ||
|
|
79964f149c | ||
|
|
bcb2471768 | ||
|
|
fcf0ee619b | ||
|
|
f3ec352066 | ||
|
|
023d0f1cc4 | ||
|
|
f526d3f972 | ||
|
|
e28f746294 |
@@ -21,3 +21,6 @@ JWT_EXPIRES_IN=
|
|||||||
QINIU_AK=
|
QINIU_AK=
|
||||||
QINIU_SK=
|
QINIU_SK=
|
||||||
QINIU_SCOPE=
|
QINIU_SCOPE=
|
||||||
|
|
||||||
|
# Python 虚拟环境路径,留空则使用系统 Python
|
||||||
|
# PYTHON_VENV_DIR=./.venv
|
||||||
|
|||||||
@@ -2,23 +2,56 @@ name: Build And Push Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths:
|
||||||
- "*.md"
|
- "**"
|
||||||
|
- "!*.md"
|
||||||
|
# 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:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
- "develop"
|
- "develop"
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
static_sync_only:
|
||||||
|
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
static_ref:
|
||||||
|
description: Static branch or version name, for example v2.22.0
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
static_ref_type:
|
||||||
|
description: Synchronize a branch, or a version branch and Git tag
|
||||||
|
type: choice
|
||||||
|
options: [branch, tag]
|
||||||
|
default: branch
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
uses: ./.github/workflows/validate.yml
|
uses: ./.github/workflows/validate.yml
|
||||||
|
|
||||||
code_gitlab:
|
code_gitlab:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -35,37 +68,8 @@ jobs:
|
|||||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
|
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
|
||||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
||||||
|
|
||||||
code_gitee:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Push to Gitee
|
|
||||||
env:
|
|
||||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
|
|
||||||
|
|
||||||
if git push --force --all gitee 2>&1; then
|
|
||||||
echo "::notice::Gitee push --all succeeded"
|
|
||||||
else
|
|
||||||
echo "::warning::Push failed, trying to create repo via API..."
|
|
||||||
curl -sS --connect-timeout 30 --max-time 60 \
|
|
||||||
-X POST "https://gitee.com/api/v5/user/repos" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"qinglong","private":"false"}' \
|
|
||||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
|
||||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
|
|
||||||
fi
|
|
||||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
|
||||||
|
|
||||||
build-static:
|
build-static:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -76,7 +80,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: ".nvmrc"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -87,6 +91,9 @@ jobs:
|
|||||||
pnpm build:back
|
pnpm build:back
|
||||||
pnpm build:info
|
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
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: qinglong-static-${{ github.sha }}
|
name: qinglong-static-${{ github.sha }}
|
||||||
@@ -94,70 +101,137 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
include-hidden-files: true
|
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
|
- name: copy to static repo
|
||||||
env:
|
env:
|
||||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||||
|
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||||
|
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p tmp
|
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||||
cd ./tmp
|
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||||
cp -rf ../static/* ./
|
|
||||||
git init -b ${GITHUB_BRANCH} && git add .
|
- name: publish verified upgrade download sizes
|
||||||
git config --local user.name 'github-actions[bot]'
|
env:
|
||||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
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:
|
static_gitlab:
|
||||||
needs: build-static
|
needs: build-static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: static-gitlab-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
- name: Push qinglong-static to GitLab
|
- name: Push qinglong-static to GitLab
|
||||||
|
env:
|
||||||
|
STATIC_BRANCH: ${{ github.ref_name }}
|
||||||
|
STATIC_REF_TYPE: ${{ github.ref_type }}
|
||||||
|
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set -euo pipefail
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||||
chmod 600 ~/.ssh/id_rsa
|
chmod 600 ~/.ssh/id_rsa
|
||||||
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||||
cd static-mirror
|
|
||||||
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
|
|
||||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
|
|
||||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
|
|
||||||
|
|
||||||
static_gitee:
|
verify-gitee-mirrors:
|
||||||
needs: build-static
|
needs: build-static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 14
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
repository: [qinglong, qinglong-static]
|
||||||
steps:
|
steps:
|
||||||
- name: Push qinglong-static to Gitee
|
- uses: actions/checkout@v6
|
||||||
|
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
|
||||||
|
# Verification runs alongside Docker builds and needs no Gitee credentials.
|
||||||
|
- name: Wait for Gitee Pull mirror
|
||||||
|
if: ${{ matrix.repository == 'qinglong' }}
|
||||||
env:
|
env:
|
||||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
|
||||||
|
MIRROR_REPOSITORY: ${{ matrix.repository }}
|
||||||
|
MIRROR_REF: ${{ github.ref_name }}
|
||||||
|
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||||
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
|
||||||
|
QL_MIRROR_NOTIFY_ON_START: 'false'
|
||||||
run: |
|
run: |
|
||||||
set +e
|
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||||
mkdir -p ~/.ssh
|
- name: Notify and wait for Gitee static mirror
|
||||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
if: ${{ matrix.repository == 'qinglong-static' }}
|
||||||
chmod 600 ~/.ssh/id_rsa
|
env:
|
||||||
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||||
cd static-mirror
|
MIRROR_REF: ${{ github.ref_name }}
|
||||||
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||||
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||||
|
run: |
|
||||||
|
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||||
|
|
||||||
if git push --force --all gitee 2>&1; then
|
sync-existing-static:
|
||||||
echo "::notice::Gitee static push succeeded"
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||||
else
|
runs-on: ubuntu-latest
|
||||||
echo "::warning::Push failed, trying to create repo via API..."
|
strategy:
|
||||||
curl -sS --connect-timeout 30 --max-time 60 \
|
fail-fast: false
|
||||||
-X POST "https://gitee.com/api/v5/user/repos" \
|
matrix:
|
||||||
-H "Content-Type: application/json" \
|
mirror: [gitlab]
|
||||||
-d '{"name":"qinglong-static","private":"false"}' \
|
concurrency:
|
||||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
cancel-in-progress: false
|
||||||
fi
|
steps:
|
||||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
- uses: actions/checkout@v6
|
||||||
|
- name: Synchronize existing static snapshot
|
||||||
|
env:
|
||||||
|
STATIC_REF: ${{ inputs.static_ref }}
|
||||||
|
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||||
|
MIRROR_HOST: ${{ matrix.mirror }}.com
|
||||||
|
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
|
||||||
|
|
||||||
|
verify-existing-static-gitee:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Wait for existing Gitee static snapshot
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
|
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||||
|
MIRROR_REF: ${{ inputs.static_ref }}
|
||||||
|
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||||
|
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||||
|
run: |
|
||||||
|
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||||
|
|
||||||
build-alpine:
|
build-alpine:
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
needs: build-static
|
needs: build-static
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.image.outputs.digest }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@@ -173,7 +247,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -223,6 +297,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push (Alpine)
|
- name: Build and push (Alpine)
|
||||||
|
id: image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
@@ -244,6 +319,8 @@ jobs:
|
|||||||
build-debian:
|
build-debian:
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
needs: build-static
|
needs: build-static
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.image.outputs.digest }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@@ -259,7 +336,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -308,6 +385,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push (Debian)
|
- name: Build and push (Debian)
|
||||||
|
id: image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
@@ -316,7 +394,7 @@ jobs:
|
|||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
# 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: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian
|
file: ./docker/Dockerfile.debian
|
||||||
push: true
|
push: true
|
||||||
@@ -327,175 +405,78 @@ jobs:
|
|||||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
||||||
|
|
||||||
build-alpine310:
|
# One unmodified amd64 package for migration acceptance only. This does not
|
||||||
if: ${{ github.ref_name == 'master' }}
|
# publish bundles or advance development/formal channel indexes.
|
||||||
needs: build-static
|
build-npm-migration-bundle:
|
||||||
runs-on: ubuntu-22.04
|
needs: build-debian
|
||||||
permissions:
|
runs-on: ubuntu-latest
|
||||||
packages: write
|
timeout-minutes: 20
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/download-artifact@v7
|
- uses: docker/setup-buildx-action@v4
|
||||||
with:
|
- name: Export the official platform package from this run's image digest
|
||||||
name: qinglong-static-${{ github.sha }}
|
env:
|
||||||
path: static/
|
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "pnpm"
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Read version from version.yaml
|
|
||||||
id: version
|
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
set -euo pipefail
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||||
|
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
|
||||||
- name: Setup timezone
|
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" .
|
||||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
- uses: actions/upload-artifact@v6
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
name: npm-migration-bundle-${{ github.sha }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
- name: Login to GHCR
|
verify-npm-package:
|
||||||
uses: docker/login-action@v4
|
needs: [build-static, build-npm-migration-bundle]
|
||||||
with:
|
runs-on: ubuntu-latest
|
||||||
registry: ghcr.io
|
timeout-minutes: 30
|
||||||
username: ${{ github.repository_owner }}
|
strategy:
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
- name: Set up QEMU
|
node: ['20.18.1', '24']
|
||||||
uses: docker/setup-qemu-action@v4
|
container: node:${{ matrix.node }}-bookworm
|
||||||
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
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/download-artifact@v7
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: qinglong-static-${{ github.sha }}
|
name: qinglong-npm-${{ github.sha }}
|
||||||
path: static/
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
version: "8.3.1"
|
name: npm-migration-bundle-${{ github.sha }}
|
||||||
- uses: actions/setup-node@v6
|
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||||
with:
|
- name: Install isolated service test tools
|
||||||
node-version: "20"
|
|
||||||
cache: "pnpm"
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Read version from version.yaml
|
|
||||||
id: version
|
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
apt-get update
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||||
|
# The published legacy launcher resets PATH to standard directories.
|
||||||
- name: Setup timezone
|
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
# remains private to each verification run.
|
||||||
|
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||||
- name: Login to DockerHub
|
- name: Install and upgrade the exact npm archive
|
||||||
uses: docker/login-action@v4
|
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:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
path: ${{ runner.temp }}/npm-panel-results/
|
||||||
|
if-no-files-found: error
|
||||||
- name: Login to GHCR
|
retention-days: 14
|
||||||
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
|
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
needs: [build-alpine, build-debian, verify-npm-package]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: build front and back
|
|
||||||
run: |
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build:front
|
|
||||||
pnpm build:back
|
|
||||||
|
|
||||||
- name: Set up Node for trusted publishing
|
- name: Set up Node for trusted publishing
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -503,18 +484,112 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: npm-panel/
|
||||||
|
|
||||||
- name: Publish npm package with OIDC
|
- name: Publish npm package with OIDC
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
name=$(node -p 'require("./package.json").name')
|
shopt -s nullglob
|
||||||
version=$(node -p 'require("./package.json").version')
|
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
|
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
|
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."
|
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
|
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||||
npm publish --access public --registry=https://registry.npmjs.org
|
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||||
else
|
else
|
||||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
||||||
|
|||||||
@@ -21,16 +21,27 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
publish:
|
publish:
|
||||||
description: Publish the verified CLI package (master only)
|
description: Publish the verified CLI package (master stable / develop prerelease)
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
prerelease_version:
|
||||||
|
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: cli-package-${{ github.workflow }}-${{ github.ref }}
|
# Keep validation runs separate from releases; never interrupt a running release.
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
# Master push releases share the same group as manually requested releases.
|
||||||
|
group: >-
|
||||||
|
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
|
||||||
|
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
|
||||||
|
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
|
||||||
|
cancel-in-progress: >-
|
||||||
|
${{ github.ref != 'refs/heads/master' &&
|
||||||
|
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
@@ -52,6 +63,20 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
|
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
|
||||||
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
|
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
|
||||||
|
- name: Set requested prerelease version before verification
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
|
||||||
|
working-directory: cli
|
||||||
|
env:
|
||||||
|
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$GITHUB_REF" != refs/heads/develop ||
|
||||||
|
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||||
|
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
|
||||||
- run: npm ci --prefix cli --no-audit --no-fund
|
- run: npm ci --prefix cli --no-audit --no-fund
|
||||||
- run: npm run check:cli
|
- run: npm run check:cli
|
||||||
- run: npm run test:cli
|
- run: npm run test:cli
|
||||||
@@ -68,13 +93,33 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
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:
|
publish:
|
||||||
needs: package
|
needs: [package, platform-smoke]
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'whyour/qinglong' &&
|
github.repository == 'whyour/qinglong' &&
|
||||||
github.ref == 'refs/heads/master' &&
|
((github.ref == 'refs/heads/master' &&
|
||||||
(github.event_name == 'push' ||
|
(github.event_name == 'push' ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.publish))
|
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
|
||||||
|
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
|
||||||
|
inputs.publish && inputs.prerelease_version != ''))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
@@ -91,6 +136,8 @@ jobs:
|
|||||||
name: qinglong-cli-${{ github.sha }}
|
name: qinglong-cli-${{ github.sha }}
|
||||||
path: cli-package
|
path: cli-package
|
||||||
- name: Publish verified npm archive
|
- name: Publish verified npm archive
|
||||||
|
env:
|
||||||
|
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -103,13 +150,30 @@ jobs:
|
|||||||
archive="${archives[0]}"
|
archive="${archives[0]}"
|
||||||
metadata=$(tar -xOf "$archive" package/package.json)
|
metadata=$(tar -xOf "$archive" package/package.json)
|
||||||
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
|
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
|
||||||
version=$(jq -er '.version | select(type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' <<< "$metadata")
|
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||||
|
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
|
||||||
|
if [[ "$version" != "$PRERELEASE_VERSION" ||
|
||||||
|
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||||
|
echo '::error::Archive version does not match the requested prerelease.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tag="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
tag=latest
|
||||||
|
else
|
||||||
|
echo '::error::Refusing to publish this branch/version combination.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
|
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
|
||||||
jq -e --arg version "$version" '. == $version' version.json > /dev/null
|
jq -e --arg version "$version" '. == $version' version.json > /dev/null
|
||||||
|
if [[ "$tag" != latest ]]; then
|
||||||
|
echo '::error::Prerelease version already exists; choose a new version to test this build.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
|
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
|
||||||
exit 0
|
exit 0
|
||||||
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
||||||
echo '::error::Could not check the published CLI version; refusing to publish.'
|
echo '::error::Could not check the published CLI version; refusing to publish.'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
npm publish "./$archive" --access public --tag latest --ignore-scripts --registry=https://registry.npmjs.org
|
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: Release readiness
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.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:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regression:
|
||||||
|
uses: ./.github/workflows/validate.yml
|
||||||
|
|
||||||
|
static:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- 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
|
||||||
|
- name: Pack the verified npm archive for isolated runtime validation
|
||||||
|
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: release-readiness-npm-panel
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
images:
|
||||||
|
needs: [regression, static]
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image: alpine
|
||||||
|
file: docker/Dockerfile
|
||||||
|
cache: alpine
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
- image: debian
|
||||||
|
file: docker/Dockerfile.debian
|
||||||
|
cache: debian
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
- name: Record source
|
||||||
|
id: source
|
||||||
|
env:
|
||||||
|
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
|
||||||
|
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: release-readiness-static
|
||||||
|
path: static/
|
||||||
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
- name: Build every release architecture
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: ${{ matrix.platforms }}
|
||||||
|
build-args: |
|
||||||
|
QL_URL=${{ steps.source.outputs.url }}
|
||||||
|
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||||
|
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||||
|
push: false
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
|
||||||
|
- name: Load native image for runtime verification
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
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 }}
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
- name: Verify native production runtime without external networking
|
||||||
|
run: |
|
||||||
|
docker run --rm --network none --entrypoint node \
|
||||||
|
qinglong-readiness:${{ matrix.image }} \
|
||||||
|
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||||
|
- name: Load ARMv6 image for SQLite and Node regression checks
|
||||||
|
if: startsWith(matrix.image, 'alpine')
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/arm/v6
|
||||||
|
build-args: |
|
||||||
|
QL_URL=${{ steps.source.outputs.url }}
|
||||||
|
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||||
|
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||||
|
tags: qinglong-readiness:${{ matrix.image }}-armv6
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
|
||||||
|
if: startsWith(matrix.image, 'alpine')
|
||||||
|
run: |
|
||||||
|
docker run --rm --network none --platform linux/arm/v6 \
|
||||||
|
-e QEMU_CPU=arm1176 --entrypoint node \
|
||||||
|
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
|
||||||
|
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
|
||||||
|
- name: Check Node binaries in every serialized image layer
|
||||||
|
if: startsWith(matrix.image, 'alpine')
|
||||||
|
run: |
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
|
||||||
|
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
|
||||||
|
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
|
||||||
|
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
|
||||||
|
- name: Export native image for isolated dev validation
|
||||||
|
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||||
|
run: |
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
|
||||||
|
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
|
||||||
|
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||||
|
with:
|
||||||
|
name: release-readiness-${{ matrix.image }}-image
|
||||||
|
path: release-readiness-${{ matrix.image }}-image.*
|
||||||
|
compression-level: 0
|
||||||
|
if-no-files-found: error
|
||||||
+10
@@ -29,6 +29,7 @@ __pycache__
|
|||||||
/shell/preload/*-notify.json
|
/shell/preload/*-notify.json
|
||||||
/shell/preload/__ql_notify__.*
|
/shell/preload/__ql_notify__.*
|
||||||
/shell/preload/lang_env.sh
|
/shell/preload/lang_env.sh
|
||||||
|
/shell/upgrade-lock.yaml
|
||||||
|
|
||||||
.deepseek/
|
.deepseek/
|
||||||
.claude/
|
.claude/
|
||||||
@@ -38,3 +39,12 @@ __pycache__
|
|||||||
|
|
||||||
# Local security audit artifacts
|
# Local security audit artifacts
|
||||||
/audit/
|
/audit/
|
||||||
|
|
||||||
|
# Local browser tests, screenshots, reports, and temporary test output
|
||||||
|
/.playwright-cli/
|
||||||
|
/output/
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
/blob-report/
|
||||||
|
/coverage/
|
||||||
|
/.nyc_output/
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
/.tmp/
|
|
||||||
/.github/
|
|
||||||
/.vscode/
|
|
||||||
/.history/
|
|
||||||
/back/**/*.ts
|
|
||||||
/back/**/*.json
|
|
||||||
/cli/
|
|
||||||
/data/
|
|
||||||
/src/
|
|
||||||
/static/**/*.js.map
|
|
||||||
/static/**/*.gz
|
|
||||||
/.editorconfig
|
|
||||||
/.gitignore
|
|
||||||
/.prettierignore
|
|
||||||
/.prettierrc
|
|
||||||
/.umirc.ts
|
|
||||||
/nodemon.json
|
|
||||||
/pnpm-lock.yaml
|
|
||||||
/tsconfig.back.json
|
|
||||||
/tsconfig.json
|
|
||||||
/typings.d.ts
|
|
||||||
/.env
|
|
||||||
@@ -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 中重新安装。
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextFunction, Request, Response, Router } from 'express';
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import {
|
import {
|
||||||
diagnoseClientIp,
|
diagnoseClientIp,
|
||||||
getTrustProxyConfig,
|
getTrustProxyConfig,
|
||||||
@@ -36,7 +37,7 @@ export default (app: Router) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send({
|
res.send({
|
||||||
code: 400,
|
code: 400,
|
||||||
message: error instanceof Error ? error.message : '配置更新失败',
|
message: error instanceof Error ? error.message : t('配置更新失败'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-7
@@ -4,7 +4,6 @@ import { Logger } from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import { join, basename } from 'path';
|
|
||||||
import { SAMPLE_FILES } from '../config/const';
|
import { SAMPLE_FILES } from '../config/const';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import ConfigService from '../services/config';
|
import ConfigService from '../services/config';
|
||||||
@@ -73,15 +72,12 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const { name, content } = req.body;
|
const { name, content } = req.body;
|
||||||
// Resolve path first to prevent traversal attacks
|
|
||||||
let basePath = config.configPath;
|
|
||||||
if (name.startsWith('data/scripts/')) {
|
if (name.startsWith('data/scripts/')) {
|
||||||
basePath = join(config.rootPath, 'data/scripts');
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
|
||||||
const normalized = resolveFileAccess(
|
const normalized = resolveFileAccess(
|
||||||
basePath,
|
config.configPath,
|
||||||
[cleanName],
|
[name],
|
||||||
config.blackFileList,
|
config.blackFileList,
|
||||||
);
|
);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
+17
-14
@@ -1,24 +1,16 @@
|
|||||||
import { Joi, celebrate } from 'celebrate';
|
import { Joi, celebrate } from 'celebrate';
|
||||||
import { NextFunction, Request, Response, Router } from 'express';
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import fs from 'fs';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import config from '../config';
|
|
||||||
import { safeJSONParse } from '../config/util';
|
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import EnvService from '../services/env';
|
import EnvService from '../services/env';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const upload = multer({
|
||||||
destination: function (req, file, cb) {
|
storage: multer.memoryStorage(),
|
||||||
cb(null, config.scriptPath);
|
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||||
},
|
|
||||||
filename: function (req, file, cb) {
|
|
||||||
cb(null, file.originalname);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const upload = multer({ storage: storage });
|
|
||||||
const labelSchema = Joi.array()
|
const labelSchema = Joi.array()
|
||||||
.items(Joi.string().trim().required())
|
.items(Joi.string().trim().required())
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -282,12 +274,23 @@ export default (app: Router) => {
|
|||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
|
let parseContent: unknown;
|
||||||
const parseContent = safeJSONParse(fileContent);
|
try {
|
||||||
|
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
|
||||||
|
} catch {
|
||||||
|
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||||
|
}
|
||||||
const data = Array.isArray(parseContent)
|
const data = Array.isArray(parseContent)
|
||||||
? parseContent
|
? parseContent
|
||||||
: [parseContent];
|
: [parseContent];
|
||||||
if (data.every((x) => x.name && x.value)) {
|
if (
|
||||||
|
data.length > 0 &&
|
||||||
|
data.every((x) =>
|
||||||
|
x && typeof x.name === 'string' &&
|
||||||
|
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
|
||||||
|
typeof x.value === 'string' && x.value.length > 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
const result = await envService.create(
|
const result = await envService.create(
|
||||||
data.map((x) => ({
|
data.map((x) => ({
|
||||||
name: x.name,
|
name: x.name,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
|||||||
import OpenService from '../services/open';
|
import OpenService from '../services/open';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { rateLimit } from 'express-rate-limit';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
@@ -100,6 +101,7 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/auth/token',
|
'/auth/token',
|
||||||
|
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
query: {
|
query: {
|
||||||
client_id: Joi.string().required(),
|
client_id: Joi.string().required(),
|
||||||
|
|||||||
+199
-14
@@ -7,20 +7,62 @@ import { Logger } from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import path, { join, parse } from 'path';
|
import path, { dirname, join, parse, resolve, sep } from 'path';
|
||||||
import ScriptService from '../services/script';
|
import ScriptService from '../services/script';
|
||||||
|
import ScriptHistoryService from '../services/scriptHistory';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import {
|
||||||
|
ScriptHistoryError,
|
||||||
|
HistoryUnavailableError,
|
||||||
|
} from '../shared/scriptHistory';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
function isPathAllowed(targetPath: string): boolean {
|
function isPathAllowed(targetPath: string): boolean {
|
||||||
const resolved = path.resolve(targetPath);
|
const resolved = path.resolve(targetPath);
|
||||||
return config.writePathList.some((x) =>
|
return config.writePathList.some((x) =>
|
||||||
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
|
Boolean(
|
||||||
|
resolveFileAccess(
|
||||||
|
x,
|
||||||
|
[resolved],
|
||||||
|
// Panel configuration secrets must not restrict user script filenames.
|
||||||
|
path.resolve(x) === path.resolve(config.configPath)
|
||||||
|
? config.blackFileList
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function existingWritableRealPath(targetPath: string): Promise<string> {
|
||||||
|
if (!isPathAllowed(targetPath)) {
|
||||||
|
throw new ScriptHistoryError(403, '暂无权限');
|
||||||
|
}
|
||||||
|
const resolved = resolve(targetPath);
|
||||||
|
for (const writableRoot of config.writePathList) {
|
||||||
|
const root = resolve(writableRoot);
|
||||||
|
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||||
|
// Keep an explicit normalized boundary check at the filesystem operation.
|
||||||
|
if (!resolved.startsWith(rootPrefix)) continue;
|
||||||
|
const realPath = await fs.realpath(resolved);
|
||||||
|
const realRoot = await fs.realpath(root);
|
||||||
|
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
||||||
|
if (
|
||||||
|
!realPath.startsWith(realRootPrefix) ||
|
||||||
|
!resolveFileAccess(
|
||||||
|
realRoot,
|
||||||
|
[realPath],
|
||||||
|
root === resolve(config.configPath) ? config.blackFileList : [],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ScriptHistoryError(403, '暂无权限');
|
||||||
|
}
|
||||||
|
return realPath;
|
||||||
|
}
|
||||||
|
throw new ScriptHistoryError(403, '暂无权限');
|
||||||
|
}
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
cb(null, config.tmpPath);
|
cb(null, config.tmpPath);
|
||||||
@@ -55,11 +97,7 @@ export default (app: Router) => {
|
|||||||
];
|
];
|
||||||
if (req.query.path) {
|
if (req.query.path) {
|
||||||
if (
|
if (
|
||||||
!resolveFileAccess(
|
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||||
config.scriptPath,
|
|
||||||
[req.query.path as string],
|
|
||||||
config.blackFileList,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return res.send({ code: 403, message: t('暂无权限') });
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
}
|
}
|
||||||
@@ -115,6 +153,82 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const historyError = (error: unknown, res: Response, next: NextFunction) => {
|
||||||
|
if (error instanceof ScriptHistoryError) {
|
||||||
|
return res.status(error.status).send({
|
||||||
|
code: error.status,
|
||||||
|
message: t(error.message),
|
||||||
|
...(error instanceof HistoryUnavailableError
|
||||||
|
? { historyUnavailable: true, currentHash: error.currentHash }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
};
|
||||||
|
const historyQuery = {
|
||||||
|
filename: Joi.string().required(),
|
||||||
|
path: Joi.string().optional().allow(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/history',
|
||||||
|
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const service = Container.get(ScriptHistoryService);
|
||||||
|
const data = await service.list(
|
||||||
|
(req.query.path as string) || '',
|
||||||
|
req.query.filename as string,
|
||||||
|
);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
historyError(error, res, next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
route.get(
|
||||||
|
'/history/detail',
|
||||||
|
celebrate({
|
||||||
|
query: Joi.object({
|
||||||
|
...historyQuery,
|
||||||
|
id: Joi.string().uuid().required(),
|
||||||
|
}).unknown(true),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const service = Container.get(ScriptHistoryService);
|
||||||
|
const data = await service.detail(
|
||||||
|
(req.query.path as string) || '',
|
||||||
|
req.query.filename as string,
|
||||||
|
req.query.id as string,
|
||||||
|
);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
historyError(error, res, next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
route.put(
|
||||||
|
'/history/restore',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
...historyQuery,
|
||||||
|
id: Joi.string().uuid().required(),
|
||||||
|
expectedHash: Joi.string().hex().length(64).required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { path = '', filename, id, expectedHash } = req.body;
|
||||||
|
const service = Container.get(ScriptHistoryService);
|
||||||
|
const data = await service.restore(path, filename, id, expectedHash);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
historyError(error, res, next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.get('/:file', (req: Request, res: Response) => {
|
route.get('/:file', (req: Request, res: Response) => {
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 410,
|
code: 410,
|
||||||
@@ -136,6 +250,11 @@ export default (app: Router) => {
|
|||||||
filename: Joi.string().required(),
|
filename: Joi.string().required(),
|
||||||
path: Joi.string().optional().allow(''),
|
path: Joi.string().optional().allow(''),
|
||||||
content: Joi.string().optional().allow(''),
|
content: Joi.string().optional().allow(''),
|
||||||
|
skipHistory: Joi.boolean().optional(),
|
||||||
|
expectedHash: Joi.string()
|
||||||
|
.hex()
|
||||||
|
.length(64)
|
||||||
|
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||||
originFilename: Joi.string().optional().allow(''),
|
originFilename: Joi.string().optional().allow(''),
|
||||||
directory: Joi.string().optional().allow(''),
|
directory: Joi.string().optional().allow(''),
|
||||||
file: Joi.string().optional().allow(''),
|
file: Joi.string().optional().allow(''),
|
||||||
@@ -195,8 +314,56 @@ export default (app: Router) => {
|
|||||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||||
return res.send({ code: 403, message: t('暂无权限') });
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
}
|
}
|
||||||
await fs.mkdir(path, { recursive: true });
|
// Check the canonical parent directly at the directory creation boundary.
|
||||||
|
const parentPath = resolve(dirname(filePath));
|
||||||
|
let parentCreated = false;
|
||||||
|
for (const writableRoot of config.writePathList) {
|
||||||
|
const root = resolve(writableRoot);
|
||||||
|
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||||
|
if (parentPath === root) {
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
} else if (parentPath.startsWith(rootPrefix)) {
|
||||||
|
await fs.mkdir(parentPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parentCreated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!parentCreated) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
const fileExists = await fileExist(filePath);
|
const fileExists = await fileExist(filePath);
|
||||||
|
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
|
||||||
|
let removeSource = filename !== originFilename;
|
||||||
|
let backupSource = '';
|
||||||
|
if (removeSource) {
|
||||||
|
const [originRealPath, targetRealPath] = await Promise.all([
|
||||||
|
existingWritableRealPath(originFilePath),
|
||||||
|
existingWritableRealPath(filePath),
|
||||||
|
]);
|
||||||
|
// Aliases of one script are an in-place save. Removing the source
|
||||||
|
// would also remove the destination behind a target symlink.
|
||||||
|
removeSource = originRealPath !== targetRealPath;
|
||||||
|
backupSource = originRealPath;
|
||||||
|
}
|
||||||
|
// Save-as removes the source after committing the destination. Keep
|
||||||
|
// its original content too: destination history only protects the
|
||||||
|
// file being overwritten, not the source being deleted.
|
||||||
|
if (removeSource) {
|
||||||
|
await fs.copyFile(
|
||||||
|
backupSource,
|
||||||
|
join(config.bakPath, originFilename.replace(/\//g, '')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const service = Container.get(ScriptHistoryService);
|
||||||
|
const data = await service.save(path, filename, content, {
|
||||||
|
skipHistory: req.body.skipHistory,
|
||||||
|
expectedHash: req.body.expectedHash,
|
||||||
|
});
|
||||||
|
if (removeSource) await rmPath(originFilePath);
|
||||||
|
return res.send({ code: 200, data });
|
||||||
|
}
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
await fs.copyFile(
|
await fs.copyFile(
|
||||||
originFilePath,
|
originFilePath,
|
||||||
@@ -209,7 +376,7 @@ export default (app: Router) => {
|
|||||||
await writeFileWithLock(filePath, content);
|
await writeFileWithLock(filePath, content);
|
||||||
return res.send({ code: 200 });
|
return res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return historyError(e, res, next);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -221,6 +388,11 @@ export default (app: Router) => {
|
|||||||
filename: Joi.string().required(),
|
filename: Joi.string().required(),
|
||||||
path: Joi.string().optional().allow(''),
|
path: Joi.string().optional().allow(''),
|
||||||
content: Joi.string().required().allow(''),
|
content: Joi.string().required().allow(''),
|
||||||
|
skipHistory: Joi.boolean().optional(),
|
||||||
|
expectedHash: Joi.string()
|
||||||
|
.hex()
|
||||||
|
.length(64)
|
||||||
|
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -238,10 +410,14 @@ export default (app: Router) => {
|
|||||||
message: t('暂无权限'),
|
message: t('暂无权限'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await writeFileWithLock(filePath, content);
|
const service = Container.get(ScriptHistoryService);
|
||||||
return res.send({ code: 200 });
|
const data = await service.save(path || '', filename, content, {
|
||||||
|
skipHistory: req.body.skipHistory,
|
||||||
|
expectedHash: req.body.expectedHash,
|
||||||
|
});
|
||||||
|
return res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return historyError(e, res, next);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -368,12 +544,21 @@ export default (app: Router) => {
|
|||||||
if (!isPathAllowed(filePath)) {
|
if (!isPathAllowed(filePath)) {
|
||||||
return res.send({ code: 403, message: t('暂无权限') });
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
}
|
}
|
||||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
const logPath = resolveFileAccess(config.logPath, [
|
||||||
|
path,
|
||||||
|
`${name}.swap`,
|
||||||
|
]);
|
||||||
|
if (!logPath) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
|
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
const result = await scriptService.stopScript(filePath, pid);
|
const result = await scriptService.stopScript(filePath, pid);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
rmPath(logPath);
|
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
|
||||||
|
if (cleanupPath) {
|
||||||
|
void rmPath(cleanupPath);
|
||||||
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
|||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import SubscriptionService from '../services/subscription';
|
import SubscriptionService from '../services/subscription';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import CronExpressionParser from 'cron-parser';
|
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
@@ -58,10 +58,7 @@ export default (app: Router) => {
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
if (
|
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||||
!req.body.schedule ||
|
|
||||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
|
||||||
) {
|
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const data = await subscriptionService.create(req.body);
|
const data = await subscriptionService.create(req.body);
|
||||||
return res.send({ code: 200, data });
|
return res.send({ code: 200, data });
|
||||||
@@ -204,11 +201,7 @@ export default (app: Router) => {
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
if (
|
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||||
!req.body.schedule ||
|
|
||||||
typeof req.body.schedule === 'object' ||
|
|
||||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
|
||||||
) {
|
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const data = await subscriptionService.update(req.body);
|
const data = await subscriptionService.update(req.body);
|
||||||
return res.send({ code: 200, data });
|
return res.send({ code: 200, data });
|
||||||
|
|||||||
+38
-9
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
|
|||||||
import UserService from '../services/user';
|
import UserService from '../services/user';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import { isDefaultAuthInfo } from '../shared/auth';
|
import { isDefaultAuthInfo } from '../shared/auth';
|
||||||
import {
|
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
|
||||||
getUniqPath,
|
import { findCronId } from '../shared/cronCommand';
|
||||||
handleLogPath,
|
|
||||||
parseVersion,
|
|
||||||
promiseExec,
|
|
||||||
} from '../config/util';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { logStreamManager } from '../shared/logStreamManager';
|
import { logStreamManager } from '../shared/logStreamManager';
|
||||||
|
import { rateLimit } from 'express-rate-limit';
|
||||||
|
|
||||||
const route = Router();
|
const route = Router();
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -208,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(
|
route.put(
|
||||||
'/reload',
|
'/reload',
|
||||||
celebrate({
|
celebrate({
|
||||||
@@ -249,6 +257,7 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/command-run',
|
'/command-run',
|
||||||
|
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
command: Joi.string().required(),
|
command: Joi.string().required(),
|
||||||
@@ -258,8 +267,13 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
const command = req.body.command;
|
const command = req.body.command;
|
||||||
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
|
const crontab = await fs
|
||||||
let id = await promiseExec(idStr);
|
.readFile(config.crontabFile, 'utf8')
|
||||||
|
.catch((e) => {
|
||||||
|
if (e.code === 'ENOENT') return '';
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
const id = findCronId(crontab, command);
|
||||||
const uniqPath = await getUniqPath(command, id);
|
const uniqPath = await getUniqPath(command, id);
|
||||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||||
const logPath = `${uniqPath}/${logTime}.log`;
|
const logPath = `${uniqPath}/${logTime}.log`;
|
||||||
@@ -274,7 +288,9 @@ export default (app: Router) => {
|
|||||||
onEnd: async (cp, endTime, diff) => {
|
onEnd: async (cp, endTime, diff) => {
|
||||||
// Close the stream after task completion
|
// Close the stream after task completion
|
||||||
try {
|
try {
|
||||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
await logStreamManager.closeStream(
|
||||||
|
await handleLogPath(logPath),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
@@ -378,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(
|
route.delete(
|
||||||
'/log',
|
'/log',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
+13
-28
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
|
|||||||
import config from './config';
|
import config from './config';
|
||||||
import Logger from './loaders/logger';
|
import Logger from './loaders/logger';
|
||||||
import { errStack } from './shared/errors';
|
import { errStack } from './shared/errors';
|
||||||
|
import { initializeLanguageSync } from './shared/languageSync';
|
||||||
import { type GrpcServerService } from './services/grpc';
|
import { type GrpcServerService } from './services/grpc';
|
||||||
import { type HttpServerService } from './services/http';
|
import { type HttpServerService } from './services/http';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class Application {
|
|||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
await this.initializeDatabase();
|
await this.initializeDatabase();
|
||||||
}
|
}
|
||||||
|
await initializeLanguageSync();
|
||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
this.startMasterProcess();
|
this.startMasterProcess();
|
||||||
} else {
|
} else {
|
||||||
@@ -39,11 +41,18 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startMasterProcess() {
|
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
|
// Fork gRPC worker first and wait for it to be ready
|
||||||
const grpcWorker = this.forkWorker('grpc');
|
const grpcWorker = this.forkWorker('grpc');
|
||||||
|
|
||||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||||
this.waitForWorkerReady(grpcWorker, 30000)
|
this.waitForWorkerReady(grpcWorker, readyTimeout)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||||
this.httpWorker = this.forkWorker('http');
|
this.httpWorker = this.forkWorker('http');
|
||||||
@@ -69,7 +78,7 @@ class Application {
|
|||||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||||
}
|
}
|
||||||
const newGrpcWorker = this.forkWorker('grpc');
|
const newGrpcWorker = this.forkWorker('grpc');
|
||||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Logger.info('gRPC worker restarted and ready');
|
Logger.info('gRPC worker restarted and ready');
|
||||||
// Re-register cron jobs by notifying the HTTP worker
|
// Re-register cron jobs by notifying the HTTP worker
|
||||||
@@ -146,32 +155,8 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setupMiddlewares(): Promise<express.Application> {
|
private async setupMiddlewares(): Promise<express.Application> {
|
||||||
// Only the HTTP worker needs an Express application and its middleware.
|
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||||
const [
|
return createHttpApplication();
|
||||||
{ default: createExpress },
|
|
||||||
{ default: helmet },
|
|
||||||
{ default: cors },
|
|
||||||
{ default: compression },
|
|
||||||
{ monitoringMiddleware },
|
|
||||||
] = await Promise.all([
|
|
||||||
import('express'),
|
|
||||||
import('helmet'),
|
|
||||||
import('cors'),
|
|
||||||
import('compression'),
|
|
||||||
import('./middlewares/monitoring'),
|
|
||||||
]);
|
|
||||||
const app = createExpress();
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (req.query.t) delete req.query.t;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: false,
|
|
||||||
}));
|
|
||||||
app.use(cors(config.cors));
|
|
||||||
app.use(compression());
|
|
||||||
app.use(monitoringMiddleware);
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupMasterShutdown() {
|
private setupMasterShutdown() {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// A single, disposable backend process. Resolve startup dependencies, but never
|
||||||
|
// run appLoader: its initData/initTask hooks install dependencies and start user work.
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import dns from 'dns';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
// The first scheduler connection can outlive its short RPC deadline. Let the
|
||||||
|
// normal single-flight recovery finish within the supervised startup budget.
|
||||||
|
async function waitForUpgradeScheduler(
|
||||||
|
readiness: { recover(): Promise<boolean> },
|
||||||
|
timeoutMs: number,
|
||||||
|
retryMs = 250,
|
||||||
|
) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
do {
|
||||||
|
if (await readiness.recover()) return;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, Math.min(retryMs, remaining)),
|
||||||
|
);
|
||||||
|
} while (Date.now() < deadline);
|
||||||
|
throw new Error('Upgrade scheduler readiness failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeUpgrade() {
|
||||||
|
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||||
|
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||||
|
dns.setDefaultResultOrder('ipv4first');
|
||||||
|
const { default: config } = await import('../config');
|
||||||
|
const { sequelize } = await import('../data');
|
||||||
|
const { default: loadDatabase } = await import('../loaders/db');
|
||||||
|
const { Container } = await import('typedi');
|
||||||
|
const { GrpcServerService } = await import('../services/grpc');
|
||||||
|
const { HttpServerService } = await import('../services/http');
|
||||||
|
const { createHttpApplication } = await import('../loaders/httpApplication');
|
||||||
|
const { default: loadServer } = await import('../loaders/server');
|
||||||
|
const { default: loadExpress } = await import('../loaders/express');
|
||||||
|
await import('../loaders/app'); // Resolve normal startup hooks without running them.
|
||||||
|
const { default: inject } = await import('../loaders/depInjector');
|
||||||
|
const grpc = Container.get(GrpcServerService);
|
||||||
|
const server = Container.get(HttpServerService);
|
||||||
|
try {
|
||||||
|
await loadDatabase();
|
||||||
|
// Exercise an actual on-disk transaction as well as schema initialization.
|
||||||
|
await sequelize.transaction(async (transaction) => {
|
||||||
|
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
if ((rows[0] as { value: number })?.value !== 42)
|
||||||
|
throw new Error('Upgrade database readback failed');
|
||||||
|
});
|
||||||
|
inject();
|
||||||
|
config.grpcPort = await grpc.initialize();
|
||||||
|
const { default: cronClient } = await import('../schedule/client');
|
||||||
|
cronClient.readiness.configure(async () => {});
|
||||||
|
await waitForUpgradeScheduler(
|
||||||
|
cronClient.readiness,
|
||||||
|
process.arch === 'arm' &&
|
||||||
|
Number(
|
||||||
|
(process.config.variables as { arm_version?: string | number })
|
||||||
|
.arm_version,
|
||||||
|
) === 6
|
||||||
|
? 60000
|
||||||
|
: 10000,
|
||||||
|
);
|
||||||
|
const app = await createHttpApplication();
|
||||||
|
loadExpress({ app });
|
||||||
|
const listener = await server.initialize(app, 0);
|
||||||
|
await loadServer({ server: listener });
|
||||||
|
const address = listener.address();
|
||||||
|
if (!address || typeof address === 'string')
|
||||||
|
throw new Error('No probe HTTP port');
|
||||||
|
const health = await new Promise<any>((resolve, reject) => {
|
||||||
|
const request = http.get(
|
||||||
|
`http://127.0.0.1:${address.port}/api/health`,
|
||||||
|
(response) => {
|
||||||
|
let body = '';
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('error', reject);
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(body);
|
||||||
|
if (
|
||||||
|
response.statusCode !== 200 ||
|
||||||
|
result.code !== 200 ||
|
||||||
|
result.data?.status !== 'ok' ||
|
||||||
|
!result.data.services?.grpc ||
|
||||||
|
!result.data.services?.http
|
||||||
|
)
|
||||||
|
throw new Error('Upgrade HTTP/gRPC health check failed');
|
||||||
|
resolve(result.data);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.setTimeout(10000, () =>
|
||||||
|
request.destroy(new Error('Upgrade health request timed out')),
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
process.send!({
|
||||||
|
type: 'upgrade-probe',
|
||||||
|
ok: true,
|
||||||
|
build: health.build,
|
||||||
|
memory: process.memoryUsage(),
|
||||||
|
pid: process.pid,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await server.shutdown();
|
||||||
|
grpc.getServer().forceShutdown();
|
||||||
|
await sequelize.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('disconnect', () => process.exit(1));
|
||||||
|
probeUpgrade()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
process.send?.({
|
||||||
|
type: 'upgrade-probe',
|
||||||
|
ok: false,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ export const SAMPLE_FILES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
|
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
|
||||||
|
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
|
||||||
|
|
||||||
export const NotificationModeStringMap = {
|
export const NotificationModeStringMap = {
|
||||||
0: 'gotify',
|
0: 'gotify',
|
||||||
|
|||||||
+93
-45
@@ -1,6 +1,5 @@
|
|||||||
import { execSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import config from './index';
|
import config from './index';
|
||||||
import { fileExist } from './util';
|
import { fileExist } from './util';
|
||||||
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
|
|||||||
|
|
||||||
let cachedConfig: GrpcTlsConfig | null = null;
|
let cachedConfig: GrpcTlsConfig | null = null;
|
||||||
|
|
||||||
function run(cmd: string, execOpts?: Record<string, unknown>): string {
|
function run(args: string[]): string {
|
||||||
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
|
return execFileSync('openssl', args, {
|
||||||
return (execSync(cmd, opts) as string).trim();
|
stdio: 'pipe',
|
||||||
}
|
timeout: 30000,
|
||||||
|
encoding: 'utf-8',
|
||||||
async function tmpFile(prefix: string): Promise<string> {
|
}).trim();
|
||||||
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||||
|
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
|
||||||
const caKeyTmp = await tmpFile('ca_key');
|
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
|
||||||
const caCertTmp = await tmpFile('ca_cert');
|
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
|
||||||
const serverKeyTmp = await tmpFile('server_key');
|
const caKeyTmp = path.join(workspace, 'ca.key');
|
||||||
const serverCsrTmp = await tmpFile('server_csr');
|
const caCertTmp = path.join(workspace, 'ca.crt');
|
||||||
const serverExtTmp = await tmpFile('server_ext');
|
const serverKeyTmp = path.join(workspace, 'server.key');
|
||||||
const clientKeyTmp = await tmpFile('client_key');
|
const serverCsrTmp = path.join(workspace, 'server.csr');
|
||||||
const clientCsrTmp = await tmpFile('client_csr');
|
const serverExtTmp = path.join(workspace, 'server.ext');
|
||||||
const clientExtTmp = await tmpFile('client_ext');
|
const clientKeyTmp = path.join(workspace, 'client.key');
|
||||||
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
|
const clientCsrTmp = path.join(workspace, 'client.csr');
|
||||||
|
const clientExtTmp = path.join(workspace, 'client.ext');
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
await fs.rm(workspace, { recursive: true, force: true });
|
||||||
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
|
||||||
try { await fs.unlink(f); } catch {}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
|
run(['genrsa', '-out', caKeyTmp, '2048']);
|
||||||
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
|
run([
|
||||||
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
|
'req',
|
||||||
|
'-new',
|
||||||
|
'-x509',
|
||||||
|
'-days',
|
||||||
|
'3650',
|
||||||
|
'-key',
|
||||||
|
caKeyTmp,
|
||||||
|
'-out',
|
||||||
|
caCertTmp,
|
||||||
|
'-subj',
|
||||||
|
'/CN=qinglong-ca/O=qinglong/C=CN',
|
||||||
|
]);
|
||||||
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||||
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||||
await fs.mkdir(certDir, { recursive: true });
|
|
||||||
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||||
|
|
||||||
// 2. 服务端
|
run(['genrsa', '-out', serverKeyTmp, '2048']);
|
||||||
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
|
run([
|
||||||
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
|
'req',
|
||||||
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
|
'-new',
|
||||||
const serverCert = run(
|
'-key',
|
||||||
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
|
serverKeyTmp,
|
||||||
|
'-out',
|
||||||
|
serverCsrTmp,
|
||||||
|
'-subj',
|
||||||
|
'/CN=grpc-server',
|
||||||
|
]);
|
||||||
|
await fs.writeFile(
|
||||||
|
serverExtTmp,
|
||||||
|
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
|
||||||
);
|
);
|
||||||
|
const serverCert = run([
|
||||||
|
'x509',
|
||||||
|
'-req',
|
||||||
|
'-days',
|
||||||
|
'3650',
|
||||||
|
'-in',
|
||||||
|
serverCsrTmp,
|
||||||
|
'-CA',
|
||||||
|
caCertTmp,
|
||||||
|
'-CAkey',
|
||||||
|
caKeyTmp,
|
||||||
|
'-CAserial',
|
||||||
|
path.join(workspace, 'ca.srl'),
|
||||||
|
'-CAcreateserial',
|
||||||
|
'-extfile',
|
||||||
|
serverExtTmp,
|
||||||
|
]);
|
||||||
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||||
|
|
||||||
// 3. 客户端
|
run(['genrsa', '-out', clientKeyTmp, '2048']);
|
||||||
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
|
run([
|
||||||
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
|
'req',
|
||||||
|
'-new',
|
||||||
|
'-key',
|
||||||
|
clientKeyTmp,
|
||||||
|
'-out',
|
||||||
|
clientCsrTmp,
|
||||||
|
'-subj',
|
||||||
|
'/CN=grpc-client',
|
||||||
|
]);
|
||||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||||
const clientCert = run(
|
const clientCert = run([
|
||||||
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
'x509',
|
||||||
);
|
'-req',
|
||||||
|
'-days',
|
||||||
|
'3650',
|
||||||
|
'-in',
|
||||||
|
clientCsrTmp,
|
||||||
|
'-CA',
|
||||||
|
caCertTmp,
|
||||||
|
'-CAkey',
|
||||||
|
caKeyTmp,
|
||||||
|
'-CAserial',
|
||||||
|
path.join(workspace, 'ca.srl'),
|
||||||
|
'-CAcreateserial',
|
||||||
|
'-extfile',
|
||||||
|
clientExtTmp,
|
||||||
|
]);
|
||||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||||
|
|
||||||
await cleanup();
|
|
||||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||||
|
|
||||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||||
} catch (e) {
|
} finally {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-26
@@ -1,5 +1,7 @@
|
|||||||
import { Subscription } from '../data/subscription';
|
import { Subscription } from '../data/subscription';
|
||||||
import isNil from 'lodash/isNil';
|
import isNil from 'lodash/isNil';
|
||||||
|
import { shellQuote } from '../shared/shellQuote';
|
||||||
|
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
|
||||||
|
|
||||||
export function formatUrl(doc: Subscription) {
|
export function formatUrl(doc: Subscription) {
|
||||||
let url = doc.url;
|
let url = doc.url;
|
||||||
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
|
|||||||
if (doc.type === 'private-repo') {
|
if (doc.type === 'private-repo') {
|
||||||
if (doc.pull_type === 'ssh-key') {
|
if (doc.pull_type === 'ssh-key') {
|
||||||
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
||||||
url = doc.url!.replace(host, doc.alias);
|
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
|
||||||
} else {
|
} else {
|
||||||
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
||||||
const { username, password } = doc.pull_option as any;
|
const { username, password } = doc.pull_option as any;
|
||||||
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCommand(doc: Subscription, url?: string) {
|
export function formatCommand(doc: Subscription, url?: string) {
|
||||||
let command = `SUB_ID=${doc.id} ql `;
|
const args =
|
||||||
let _url = url || formatUrl(doc).url;
|
doc.type === 'file'
|
||||||
const {
|
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
|
||||||
type,
|
: [
|
||||||
whitelist,
|
'repo',
|
||||||
blacklist,
|
url || formatUrl(doc).url,
|
||||||
dependences,
|
doc.whitelist || '',
|
||||||
branch,
|
doc.blacklist || '',
|
||||||
extensions,
|
doc.dependences || '',
|
||||||
proxy,
|
doc.branch || '',
|
||||||
autoAddCron,
|
doc.extensions || '',
|
||||||
autoDelCron,
|
doc.proxy || '',
|
||||||
} = doc;
|
];
|
||||||
if (type === 'file') {
|
args.push(
|
||||||
command += `raw "${_url}" "${proxy || ''}" "${
|
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
|
||||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
|
||||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
);
|
||||||
} else {
|
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
|
||||||
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
|
.map((arg) => shellQuote(arg!))
|
||||||
dependences || ''
|
.join(' ')}`;
|
||||||
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
|
|
||||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
|
||||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
|
||||||
}
|
|
||||||
return command;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+223
-100
@@ -1,18 +1,25 @@
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
import { statSync } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import psTreeFun from 'ps-tree';
|
import psTreeFun from 'ps-tree';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { load } from 'js-yaml';
|
import { load } from 'js-yaml';
|
||||||
import config from './index';
|
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 Logger from '../loaders/logger';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
import { DependenceTypes } from '../data/dependence';
|
import { DependenceTypes } from '../data/dependence';
|
||||||
import { FormData } from 'undici';
|
import { FormData } from 'undici';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { maybeSudo, isInContainer } from './container';
|
import { maybeSudo, isInContainer } from './container';
|
||||||
|
import { shellQuote } from '../shared/shellQuote';
|
||||||
import { resolveFileAccess } from '../shared/fileAccess';
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
|
import {
|
||||||
|
mirrorWriteCommand,
|
||||||
|
normalizeMirror,
|
||||||
|
rewriteMirrorSources,
|
||||||
|
} from '../shared/linuxMirror';
|
||||||
|
|
||||||
export * from './share';
|
export * from './share';
|
||||||
|
|
||||||
@@ -670,7 +677,9 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
|||||||
throw new Error('Invalid timezone');
|
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')}`);
|
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -680,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 {
|
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 = {
|
const baseCommands = {
|
||||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||||
[DependenceTypes.python3]: `
|
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||||
python3 -c "exec('''
|
`from importlib.metadata import version, PackageNotFoundError
|
||||||
name='${name}'
|
import importlib.util
|
||||||
|
import sys
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version
|
try:
|
||||||
print(version(name))
|
from packaging.requirements import Requirement
|
||||||
except:
|
except ImportError:
|
||||||
import importlib.util as u
|
from pip._vendor.packaging.requirements import Requirement
|
||||||
import importlib.metadata as m
|
requirement = Requirement(sys.argv[1])
|
||||||
spec=u.find_spec(name)
|
if requirement.marker and not requirement.marker.evaluate():
|
||||||
print(name if spec else '')
|
print('not applicable')
|
||||||
''')"`,
|
else:
|
||||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
try:
|
||||||
? `apk info -es ${name}`
|
installed = version(requirement.name)
|
||||||
: maybeSudo(`dpkg-query -s ${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];
|
return baseCommands[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
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 = {
|
const baseCommands = {
|
||||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||||
[DependenceTypes.python3]:
|
[DependenceTypes.linux]:
|
||||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
getOsTypeSync() === 'Alpine'
|
||||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
? 'apk add --no-check-certificate'
|
||||||
? 'apk add --no-check-certificate'
|
: maybeSudo('apt-get install -y'),
|
||||||
: maybeSudo('apt-get install -y'),
|
|
||||||
};
|
};
|
||||||
|
return `${baseCommands[type]} ${name.trim()}`;
|
||||||
let command = baseCommands[type];
|
|
||||||
|
|
||||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
|
||||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${command} ${name.trim()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUninstallCommand(
|
export function getUninstallCommand(
|
||||||
type: DependenceTypes,
|
type: DependenceTypes,
|
||||||
name: string,
|
name: string,
|
||||||
): 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 = {
|
const baseCommands = {
|
||||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||||
[DependenceTypes.python3]:
|
[DependenceTypes.linux]:
|
||||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
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()}`;
|
return `${baseCommands[type]} ${name.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,75 +931,73 @@ export async function detectOS(): Promise<
|
|||||||
return osType;
|
return osType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentMirrorDomain(
|
|
||||||
filePath: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
|
||||||
const lines = fileContent.split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim().startsWith('#')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = line.match(/https?:\/\/[^\/]+/);
|
|
||||||
if (match) {
|
|
||||||
return match[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function replaceDomainInFile(
|
|
||||||
filePath: string,
|
|
||||||
oldDomainWithScheme: string,
|
|
||||||
newDomainWithScheme: string,
|
|
||||||
): Promise<void> {
|
|
||||||
let fileContent = await fs.readFile(filePath, 'utf8');
|
|
||||||
let updatedContent = fileContent.replace(
|
|
||||||
new RegExp(oldDomainWithScheme, 'g'),
|
|
||||||
newDomainWithScheme,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!newDomainWithScheme.endsWith('/')) {
|
|
||||||
newDomainWithScheme += '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFileWithLock(filePath, updatedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _updateLinuxMirror(
|
async function _updateLinuxMirror(
|
||||||
osType: string,
|
osType: string,
|
||||||
mirrorDomainWithScheme: string,
|
mirror: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const S = isInContainer() ? 'sudo ' : '';
|
const defaults: Record<string, string> = {
|
||||||
let filePath: string, currentDomainWithScheme: string | null;
|
Debian: 'http://deb.debian.org/debian',
|
||||||
switch (osType) {
|
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
|
||||||
case 'Debian':
|
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
|
||||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
};
|
||||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
if (!defaults[osType])
|
||||||
if (currentDomainWithScheme) {
|
throw new Error('Unsupported OS type for updating mirrors.');
|
||||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
const target = mirror || defaults[osType];
|
||||||
} else {
|
normalizeMirror(target);
|
||||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||||
|
const update =
|
||||||
|
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||||
|
let file =
|
||||||
|
osType === 'Alpine'
|
||||||
|
? '/etc/apk/repositories'
|
||||||
|
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||||
|
let content = '';
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(file, 'utf8');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
if (osType !== 'Alpine') {
|
||||||
|
try {
|
||||||
|
file = '/etc/apt/sources.list';
|
||||||
|
content = await fs.readFile(file, 'utf8');
|
||||||
|
} catch (legacyError: any) {
|
||||||
|
if (legacyError.code !== 'ENOENT') throw legacyError;
|
||||||
|
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||||
}
|
}
|
||||||
case 'Ubuntu':
|
}
|
||||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
|
||||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
|
||||||
if (currentDomainWithScheme) {
|
|
||||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
|
||||||
} else {
|
|
||||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
|
||||||
}
|
|
||||||
case 'Alpine':
|
|
||||||
filePath = '/etc/apk/repositories';
|
|
||||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
|
||||||
if (currentDomainWithScheme) {
|
|
||||||
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
|
|
||||||
} else {
|
|
||||||
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw Error('Unsupported OS type for updating mirrors.');
|
|
||||||
}
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
const release = await fs.readFile('/etc/os-release', 'utf8');
|
||||||
|
if (osType === 'Alpine') {
|
||||||
|
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
|
||||||
|
if (!version)
|
||||||
|
throw new Error('Cannot determine Alpine repository version');
|
||||||
|
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
|
||||||
|
} else {
|
||||||
|
// The fallback may have read an empty legacy .list. Deb822 stanzas must
|
||||||
|
// be written to a .sources file, leaving the legacy file untouched.
|
||||||
|
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||||
|
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
|
||||||
|
if (!codename)
|
||||||
|
throw new Error('Cannot determine APT repository codename');
|
||||||
|
const ubuntu = osType === 'Ubuntu';
|
||||||
|
content = `Types: deb\nURIs: ${
|
||||||
|
defaults[osType]
|
||||||
|
}\nSuites: ${codename} ${codename}-updates${
|
||||||
|
ubuntu ? ` ${codename}-backports` : ''
|
||||||
|
}\nComponents: ${
|
||||||
|
ubuntu ? 'main restricted universe multiverse' : 'main'
|
||||||
|
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
|
||||||
|
if (!ubuntu)
|
||||||
|
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mirrorWriteCommand(
|
||||||
|
file,
|
||||||
|
rewriteMirrorSources(content, target),
|
||||||
|
S,
|
||||||
|
update,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export enum AuthDataType {
|
|||||||
'removeLogFrequency' = 'removeLogFrequency',
|
'removeLogFrequency' = 'removeLogFrequency',
|
||||||
'systemConfig' = 'systemConfig',
|
'systemConfig' = 'systemConfig',
|
||||||
'authConfig' = 'authConfig',
|
'authConfig' = 'authConfig',
|
||||||
|
'notifyLog' = 'notifyLog',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NotifyStatus = LoginStatus;
|
||||||
|
export type NotifyStatus = LoginStatus;
|
||||||
|
|
||||||
export interface SystemConfigInfo {
|
export interface SystemConfigInfo {
|
||||||
lang?: string;
|
lang?: string;
|
||||||
panelTitle?: string;
|
panelTitle?: string;
|
||||||
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
|
|||||||
status?: LoginStatus;
|
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 {
|
export interface TokenInfo {
|
||||||
value: string;
|
value: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -88,6 +103,7 @@ export interface AuthInfo {
|
|||||||
export type SystemModelInfo = SystemConfigInfo &
|
export type SystemModelInfo = SystemConfigInfo &
|
||||||
Partial<NotificationInfo> &
|
Partial<NotificationInfo> &
|
||||||
LoginLogInfo &
|
LoginLogInfo &
|
||||||
|
Partial<NotifyLogInfo> &
|
||||||
Partial<AuthInfo>;
|
Partial<AuthInfo>;
|
||||||
|
|
||||||
export interface SystemInstance
|
export interface SystemInstance
|
||||||
|
|||||||
+12
-3
@@ -6,7 +6,7 @@ import config from '../config';
|
|||||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||||
import { getPlatform, getToken } from '../config/util';
|
import { getPlatform, getToken } from '../config/util';
|
||||||
import rewrite from 'express-urlrewrite';
|
import rewrite from 'express-urlrewrite';
|
||||||
import { errors } from 'celebrate';
|
import validationErrors from '../middlewares/validationErrors';
|
||||||
import { serveEnv } from '../config/serverEnv';
|
import { serveEnv } from '../config/serverEnv';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||||
@@ -14,6 +14,7 @@ import { AuthInfo } from '../data/system';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import { AppScope } from '../data/open';
|
import { AppScope } from '../data/open';
|
||||||
|
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||||
|
|
||||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||||
@@ -178,7 +179,14 @@ export default ({ app }: { app: Application }) => {
|
|||||||
app.use(rewrite('/open/*', '/api/$1'));
|
app.use(rewrite('/open/*', '/api/$1'));
|
||||||
app.use(config.api.prefix, routes());
|
app.use(config.api.prefix, routes());
|
||||||
|
|
||||||
app.get('*', (_, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
|
// Don't serve index.html for API routes
|
||||||
|
if (
|
||||||
|
req.path === config.api.prefix ||
|
||||||
|
req.path.startsWith(`${config.api.prefix}/`)
|
||||||
|
) {
|
||||||
|
return res.status(404).json({ code: 404, message: 'Not Found' });
|
||||||
|
}
|
||||||
const indexPath = path.join(frontendPath, 'index.html');
|
const indexPath = path.join(frontendPath, 'index.html');
|
||||||
res.sendFile(indexPath, (err) => {
|
res.sendFile(indexPath, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -189,7 +197,8 @@ export default ({ app }: { app: Application }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(errors());
|
app.use(validationErrors);
|
||||||
|
app.use(uniqueConstraintError);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type express from 'express';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export async function createHttpApplication(): Promise<express.Application> {
|
||||||
|
// Only the HTTP worker needs an Express application and its middleware.
|
||||||
|
const [
|
||||||
|
{ default: createExpress },
|
||||||
|
{ default: helmet },
|
||||||
|
{ default: cors },
|
||||||
|
{ default: compression },
|
||||||
|
{ monitoringMiddleware },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('express'),
|
||||||
|
import('helmet'),
|
||||||
|
import('cors'),
|
||||||
|
import('compression'),
|
||||||
|
import('../middlewares/monitoring'),
|
||||||
|
]);
|
||||||
|
const app = createExpress();
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.query.t) delete req.query.t;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(cors(config.cors));
|
||||||
|
app.use(compression());
|
||||||
|
app.use(monitoringMiddleware);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
+12
-26
@@ -13,12 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
|
|||||||
import SystemService from '../services/system';
|
import SystemService from '../services/system';
|
||||||
import UserService from '../services/user';
|
import UserService from '../services/user';
|
||||||
import { writeFile, readFile } from 'fs/promises';
|
import { writeFile, readFile } from 'fs/promises';
|
||||||
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||||
import OpenService from '../services/open';
|
import OpenService from '../services/open';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import cronClient from '../schedule/client';
|
import cronClient from '../schedule/client';
|
||||||
import { AppModel } from '../data/open';
|
|
||||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||||
import { setLang, systemLang } from '../shared/i18n';
|
import { setLang, systemLang } from '../shared/i18n';
|
||||||
|
|
||||||
@@ -31,24 +30,7 @@ export default async () => {
|
|||||||
const openService = Container.get(OpenService);
|
const openService = Container.get(OpenService);
|
||||||
|
|
||||||
// 初始化增加系统配置
|
// 初始化增加系统配置
|
||||||
let systemApp = (
|
await openService.initializeSystemApp();
|
||||||
await AppModel.findOne({
|
|
||||||
where: { name: 'system' },
|
|
||||||
})
|
|
||||||
)?.get({ plain: true });
|
|
||||||
if (!systemApp) {
|
|
||||||
systemApp = await AppModel.create({
|
|
||||||
name: 'system',
|
|
||||||
scopes: ['crons', 'system', 'dashboard'],
|
|
||||||
client_id: createRandomString(12, 12),
|
|
||||||
client_secret: createRandomString(24, 24),
|
|
||||||
});
|
|
||||||
} else if (!systemApp.scopes.includes('dashboard')) {
|
|
||||||
await AppModel.update(
|
|
||||||
{ scopes: [...systemApp.scopes, 'dashboard'] },
|
|
||||||
{ where: { name: 'system' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [systemConfig] = await SystemModel.findOrCreate({
|
const [systemConfig] = await SystemModel.findOrCreate({
|
||||||
where: { type: AuthDataType.systemConfig },
|
where: { type: AuthDataType.systemConfig },
|
||||||
});
|
});
|
||||||
@@ -144,7 +126,11 @@ export default async () => {
|
|||||||
|
|
||||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||||
await RunningInstanceModel.update(
|
await RunningInstanceModel.update(
|
||||||
{ status: InstanceStatus.stopped },
|
{
|
||||||
|
status: InstanceStatus.stopped,
|
||||||
|
finished_at: Math.floor(Date.now() / 1000),
|
||||||
|
// The previous process's exit code cannot be recovered after a restart.
|
||||||
|
},
|
||||||
{ where: { status: InstanceStatus.running } },
|
{ where: { status: InstanceStatus.running } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -237,15 +223,15 @@ export default async () => {
|
|||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
// 初始化保存一次ck和定时任务数据
|
// 初始化保存一次ck和定时任务数据
|
||||||
cronClient.readiness.configure(() => cronService.autosave_crontab(true));
|
cronClient.readiness.configure(async () => {
|
||||||
|
await cronService.autosave_crontab(true);
|
||||||
|
await systemService.restoreCronConcurrency();
|
||||||
|
});
|
||||||
await cronClient.readiness.recover();
|
await cronClient.readiness.recover();
|
||||||
|
|
||||||
await envService.set_envs();
|
await envService.set_envs();
|
||||||
|
|
||||||
const authInfo = await userService.getAuthInfo();
|
const authInfo = await userService.getAuthInfo();
|
||||||
const apps = await openService.findApps();
|
|
||||||
await shareStore.updateAuthInfo(authInfo);
|
await shareStore.updateAuthInfo(authInfo);
|
||||||
if (apps?.length) {
|
await openService.refreshApps();
|
||||||
await shareStore.updateApps(apps);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import config from '../config';
|
|||||||
import { fileExist } from '../config/util';
|
import { fileExist } from '../config/util';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
|
import Logger from './logger';
|
||||||
|
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
@@ -24,6 +26,7 @@ export default async () => {
|
|||||||
if (await fileExist(tokenFile)) {
|
if (await fileExist(tokenFile)) {
|
||||||
tokenCommand = `node ${tokenFile}`;
|
tokenCommand = `node ${tokenFile}`;
|
||||||
}
|
}
|
||||||
|
tokenCommand += ' --renew';
|
||||||
const cron = {
|
const cron = {
|
||||||
id: NaN,
|
id: NaN,
|
||||||
name: t('生成token'),
|
name: t('生成token'),
|
||||||
@@ -60,7 +63,7 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemService.updateTimezone(data.info);
|
systemService.updateTimezone(data.info);
|
||||||
|
|
||||||
// Apply global SSH key if configured
|
// Apply global SSH key if configured
|
||||||
if (data.info.globalSshKey) {
|
if (data.info.globalSshKey) {
|
||||||
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||||
@@ -70,6 +73,12 @@ export default async () => {
|
|||||||
await subscriptionService.setSshConfig();
|
await subscriptionService.setSshConfig();
|
||||||
const subs = await subscriptionService.list();
|
const subs = await subscriptionService.list();
|
||||||
for (const sub of subs) {
|
for (const sub of subs) {
|
||||||
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
|
try {
|
||||||
|
const doc = sub.get({ plain: true });
|
||||||
|
assertSubscriptionAlias(doc.alias);
|
||||||
|
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ErrorRequestHandler } from 'express';
|
||||||
|
import { UniqueConstraintError } from 'sequelize';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
|
// Handle the database constraint itself so concurrent writes are covered too.
|
||||||
|
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
|
||||||
|
if (!(err instanceof UniqueConstraintError)) return next(err);
|
||||||
|
// Constraint details can contain environment values or application credentials.
|
||||||
|
res
|
||||||
|
.status(409)
|
||||||
|
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default uniqueConstraintError;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { errors, isCelebrateError } from 'celebrate';
|
||||||
|
import { ErrorRequestHandler } from 'express';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
|
const respond = errors();
|
||||||
|
|
||||||
|
// Schemas are compiled once at startup; resolve their messages per request so
|
||||||
|
// changing the system language also changes errors from those existing schemas.
|
||||||
|
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
|
||||||
|
if (isCelebrateError(error)) {
|
||||||
|
for (const detail of error.details.values()) {
|
||||||
|
detail.message = detail.details.map((item) => t(item.message)).join('. ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return respond(error, req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validationErrors;
|
||||||
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
|
|||||||
optional NotificationInfo notificationInfo = 3;
|
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 {
|
service Api {
|
||||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||||
@@ -271,6 +280,7 @@ service Api {
|
|||||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||||
|
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
|
||||||
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
||||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||||
|
|||||||
+175
-1
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v6.31.1
|
||||||
// source: back/protos/api.proto
|
// source: back/protos/api.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
|
|||||||
notificationInfo?: NotificationInfo | undefined;
|
notificationInfo?: NotificationInfo | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordNotifyLogRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
success: boolean;
|
||||||
|
notifyType: string;
|
||||||
|
scriptName: string;
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
function createBaseEnvItem(): EnvItem {
|
function createBaseEnvItem(): EnvItem {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
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 type ApiService = typeof ApiService;
|
||||||
export const ApiService = {
|
export const ApiService = {
|
||||||
getEnvs: {
|
getEnvs: {
|
||||||
@@ -4388,6 +4537,15 @@ export const ApiService = {
|
|||||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
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: {
|
getCronDetail: {
|
||||||
path: "/com.ql.api.Api/GetCronDetail",
|
path: "/com.ql.api.Api/GetCronDetail",
|
||||||
requestStream: false,
|
requestStream: false,
|
||||||
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
|
|||||||
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
||||||
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
||||||
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
||||||
|
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
|
||||||
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
||||||
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
||||||
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
||||||
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
|
|||||||
options: Partial<CallOptions>,
|
options: Partial<CallOptions>,
|
||||||
callback: (error: ServiceError | null, response: Response) => void,
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
): ClientUnaryCall;
|
): 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(
|
getCronDetail(
|
||||||
request: CronDetailRequest,
|
request: CronDetailRequest,
|
||||||
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package com.ql.cron;
|
|||||||
service Cron {
|
service Cron {
|
||||||
rpc addCron(AddCronRequest) returns (AddCronResponse);
|
rpc addCron(AddCronRequest) returns (AddCronResponse);
|
||||||
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
|
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
|
||||||
|
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message ISchedule { string schedule = 1; }
|
message ISchedule { string schedule = 1; }
|
||||||
@@ -27,3 +28,6 @@ message AddCronResponse {}
|
|||||||
message DeleteCronRequest { repeated string ids = 1; }
|
message DeleteCronRequest { repeated string ids = 1; }
|
||||||
|
|
||||||
message DeleteCronResponse {}
|
message DeleteCronResponse {}
|
||||||
|
|
||||||
|
// Zero restores the default limit.
|
||||||
|
message SetConcurrencyRequest { int32 concurrency = 1; }
|
||||||
|
|||||||
+89
-1
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v5.28.3
|
||||||
// source: back/protos/cron.proto
|
// source: back/protos/cron.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
@@ -48,6 +48,11 @@ export interface DeleteCronRequest {
|
|||||||
export interface DeleteCronResponse {
|
export interface DeleteCronResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zero restores the default limit. */
|
||||||
|
export interface SetConcurrencyRequest {
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
function createBaseISchedule(): ISchedule {
|
function createBaseISchedule(): ISchedule {
|
||||||
return { schedule: "" };
|
return { schedule: "" };
|
||||||
}
|
}
|
||||||
@@ -452,6 +457,64 @@ export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createBaseSetConcurrencyRequest(): SetConcurrencyRequest {
|
||||||
|
return { concurrency: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetConcurrencyRequest: MessageFns<SetConcurrencyRequest> = {
|
||||||
|
encode(message: SetConcurrencyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||||
|
if (message.concurrency !== 0) {
|
||||||
|
writer.uint32(8).int32(message.concurrency);
|
||||||
|
}
|
||||||
|
return writer;
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(input: BinaryReader | Uint8Array, length?: number): SetConcurrencyRequest {
|
||||||
|
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||||
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
|
const message = createBaseSetConcurrencyRequest();
|
||||||
|
while (reader.pos < end) {
|
||||||
|
const tag = reader.uint32();
|
||||||
|
switch (tag >>> 3) {
|
||||||
|
case 1: {
|
||||||
|
if (tag !== 8) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.concurrency = reader.int32();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((tag & 7) === 4 || tag === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
reader.skip(tag & 7);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
fromJSON(object: any): SetConcurrencyRequest {
|
||||||
|
return { concurrency: isSet(object.concurrency) ? globalThis.Number(object.concurrency) : 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
toJSON(message: SetConcurrencyRequest): unknown {
|
||||||
|
const obj: any = {};
|
||||||
|
if (message.concurrency !== 0) {
|
||||||
|
obj.concurrency = Math.round(message.concurrency);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
|
||||||
|
create<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(base?: I): SetConcurrencyRequest {
|
||||||
|
return SetConcurrencyRequest.fromPartial(base ?? ({} as any));
|
||||||
|
},
|
||||||
|
fromPartial<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(object: I): SetConcurrencyRequest {
|
||||||
|
const message = createBaseSetConcurrencyRequest();
|
||||||
|
message.concurrency = object.concurrency ?? 0;
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type CronService = typeof CronService;
|
export type CronService = typeof CronService;
|
||||||
export const CronService = {
|
export const CronService = {
|
||||||
addCron: {
|
addCron: {
|
||||||
@@ -472,11 +535,21 @@ export const CronService = {
|
|||||||
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
|
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
|
||||||
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
|
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
|
||||||
},
|
},
|
||||||
|
setConcurrency: {
|
||||||
|
path: "/com.ql.cron.Cron/setConcurrency",
|
||||||
|
requestStream: false,
|
||||||
|
responseStream: false,
|
||||||
|
requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()),
|
||||||
|
requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value),
|
||||||
|
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
|
||||||
|
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface CronServer extends UntypedServiceImplementation {
|
export interface CronServer extends UntypedServiceImplementation {
|
||||||
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
|
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
|
||||||
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
|
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
|
||||||
|
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronClient extends Client {
|
export interface CronClient extends Client {
|
||||||
@@ -510,6 +583,21 @@ export interface CronClient extends Client {
|
|||||||
options: Partial<CallOptions>,
|
options: Partial<CallOptions>,
|
||||||
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
|
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
|
||||||
): ClientUnaryCall;
|
): ClientUnaryCall;
|
||||||
|
setConcurrency(
|
||||||
|
request: SetConcurrencyRequest,
|
||||||
|
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
|
setConcurrency(
|
||||||
|
request: SetConcurrencyRequest,
|
||||||
|
metadata: Metadata,
|
||||||
|
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
|
setConcurrency(
|
||||||
|
request: SetConcurrencyRequest,
|
||||||
|
metadata: Metadata,
|
||||||
|
options: Partial<CallOptions>,
|
||||||
|
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
|
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
|
||||||
|
|||||||
+53
-74
@@ -1,11 +1,11 @@
|
|||||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||||
import nodeSchedule from 'node-schedule';
|
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
import { runCron } from '../shared/runCron';
|
import { runCron } from '../shared/runCron';
|
||||||
import Logger from '../loaders/logger';
|
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.
|
// Validate the entire batch before replacing any existing jobs.
|
||||||
const isValidCronField = (cron: string): boolean => {
|
const isValidCronField = (cron: string): boolean => {
|
||||||
@@ -24,11 +24,7 @@ const addCron = (
|
|||||||
|
|
||||||
if (!isValidCronField(schedule)) {
|
if (!isValidCronField(schedule)) {
|
||||||
validationErrors.push(
|
validationErrors.push(
|
||||||
tf(
|
tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
|
||||||
'任务ID %s: 无效的 cron 表达式 "%s"',
|
|
||||||
String(id),
|
|
||||||
schedule,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,79 +52,62 @@ const addCron = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery replaces the whole snapshot, including deletions and disabled jobs.
|
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
|
||||||
// Validation above must finish before touching the previous schedule.
|
const prepared = new Map<string, CronJob[]>();
|
||||||
if (call.request.replace) {
|
try {
|
||||||
for (const jobs of scheduleStacks.values()) {
|
for (const item of call.request.crons) {
|
||||||
for (const job of jobs) job?.cancel();
|
const jobs: CronJob[] = [];
|
||||||
|
prepared.get(item.id)?.forEach((job) => job.cancel());
|
||||||
|
prepared.set(item.id, jobs);
|
||||||
|
for (const schedule of [
|
||||||
|
item.schedule,
|
||||||
|
...(item.extra_schedules || []).map((x) => x.schedule),
|
||||||
|
]) {
|
||||||
|
jobs.push(
|
||||||
|
createCronJob(
|
||||||
|
schedule,
|
||||||
|
async () => {
|
||||||
|
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
|
||||||
|
await runCron(
|
||||||
|
item.command,
|
||||||
|
item,
|
||||||
|
() => scheduleStacks.get(item.id) === jobs,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${item.id}: ${item.name || ''}`,
|
||||||
|
logger: Logger,
|
||||||
|
start: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scheduleStacks.clear();
|
} catch (error) {
|
||||||
|
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
|
||||||
|
const err: any = new Error(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
err.code = status.INVALID_ARGUMENT;
|
||||||
|
err.details = err.message;
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 第二遍:注册所有任务 =====
|
if (call.request.replace) {
|
||||||
for (const item of call.request.crons) {
|
for (const jobs of scheduleStacks.values())
|
||||||
const { id, schedule, command, extra_schedules, name } = item;
|
jobs.forEach((job) => job?.cancel());
|
||||||
|
scheduleStacks.clear();
|
||||||
// 取消该 id 已有的旧任务
|
}
|
||||||
if (scheduleStacks.has(id)) {
|
for (const [id, jobs] of prepared) {
|
||||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
scheduleStacks.get(id)?.forEach((job) => job.cancel());
|
||||||
}
|
scheduleStacks.set(id, jobs);
|
||||||
|
jobs.forEach((job) => job.start());
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
|
||||||
id,
|
id,
|
||||||
name,
|
jobs.length,
|
||||||
schedule,
|
|
||||||
command,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (extra_schedules?.length) {
|
|
||||||
extra_schedules.forEach((x) => {
|
|
||||||
Logger.info(
|
|
||||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
x.schedule,
|
|
||||||
command,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
|
||||||
runCron(command, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mainJob) {
|
|
||||||
Logger.warn(
|
|
||||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
|
||||||
id,
|
|
||||||
schedule,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraJobs = extra_schedules?.length
|
|
||||||
? extra_schedules.map((x) => {
|
|
||||||
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
|
||||||
runCron(command, item);
|
|
||||||
});
|
|
||||||
if (!job) {
|
|
||||||
Logger.warn(
|
|
||||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
|
||||||
id,
|
|
||||||
x.schedule,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return job;
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// 过滤 null(兜底保护,正常情况下预校验已拦截)
|
|
||||||
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
|
|
||||||
if (jobs.length > 0) {
|
|
||||||
scheduleStacks.set(id, jobs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(null, null);
|
callback(null, null);
|
||||||
|
|||||||
+25
-2
@@ -16,6 +16,7 @@ import {
|
|||||||
MoveEnvRequest,
|
MoveEnvRequest,
|
||||||
Response,
|
Response,
|
||||||
SystemNotifyRequest,
|
SystemNotifyRequest,
|
||||||
|
RecordNotifyLogRequest,
|
||||||
UpdateEnvNamesRequest,
|
UpdateEnvNamesRequest,
|
||||||
UpdateEnvRequest,
|
UpdateEnvRequest,
|
||||||
} from '../protos/api';
|
} from '../protos/api';
|
||||||
@@ -238,7 +239,8 @@ export const systemNotify = async (
|
|||||||
const data = await systemService.notify({
|
const data = await systemService.notify({
|
||||||
title: call.request.title,
|
title: call.request.title,
|
||||||
content: call.request.content,
|
content: call.request.content,
|
||||||
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
notificationInfo: call.request
|
||||||
|
.notificationInfo as unknown as NotificationInfo,
|
||||||
});
|
});
|
||||||
callback(null, data);
|
callback(null, data);
|
||||||
} catch (e: any) {
|
} 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 => {
|
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
// 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 {
|
return {
|
||||||
...cron,
|
...cron,
|
||||||
labels: cron.labels ?? [],
|
labels: cron.labels ?? [],
|
||||||
|
|||||||
+26
-1
@@ -62,16 +62,41 @@ class Client {
|
|||||||
return this._client;
|
return this._client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setConcurrency(concurrency: number): Promise<void> {
|
||||||
|
await this.waitForReady(2000);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client.setConcurrency(
|
||||||
|
{ concurrency }, new Metadata(), { deadline: Date.now() + 5000 },
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||||
|
this.readiness.invalidate();
|
||||||
|
Object.assign(err, { status: 503 });
|
||||||
|
}
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async addCron(
|
async addCron(
|
||||||
request: AddCronRequest['crons'],
|
request: AddCronRequest['crons'],
|
||||||
replace = false
|
replace = false
|
||||||
): Promise<AddCronResponse> {
|
): Promise<AddCronResponse> {
|
||||||
await this.waitForReady(2000);
|
await this.waitForReady(2000);
|
||||||
|
// Include every rule in a recovery snapshot, but bound a stuck worker.
|
||||||
|
const ruleCount = request.reduce(
|
||||||
|
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.client.addCron(
|
this.client.addCron(
|
||||||
{ crons: request, replace },
|
{ crons: request, replace },
|
||||||
new Metadata(),
|
new Metadata(),
|
||||||
{ deadline: Date.now() + 5000 },
|
{ deadline: Date.now() + registrationTimeoutMs },
|
||||||
(err, res) => {
|
(err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import nodeSchedule from 'node-schedule';
|
import { CronJob } from '../shared/cronScheduler';
|
||||||
import { ToadScheduler } from 'toad-scheduler';
|
import { ToadScheduler } from 'toad-scheduler';
|
||||||
|
|
||||||
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
|
export const scheduleStacks = new Map<string, CronJob[]>();
|
||||||
|
|
||||||
export const intervalSchedule = new ToadScheduler();
|
export const intervalSchedule = new ToadScheduler();
|
||||||
|
|||||||
+17
-16
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||||
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
|
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
@@ -9,24 +10,24 @@ const delCron = (
|
|||||||
) => {
|
) => {
|
||||||
for (const id of call.request.ids) {
|
for (const id of call.request.ids) {
|
||||||
if (scheduleStacks.has(id)) {
|
if (scheduleStacks.has(id)) {
|
||||||
Logger.info(
|
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
|
||||||
'[schedule][取消定时任务] 任务ID: %s',
|
// 防御性过滤历史调度栈中的空任务,
|
||||||
id,
|
|
||||||
);
|
|
||||||
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
|
|
||||||
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||||
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||||
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
scheduleStacks
|
||||||
try {
|
.get(id)
|
||||||
x.cancel();
|
?.filter((x) => x != null)
|
||||||
} catch (error: any) {
|
.forEach((x) => {
|
||||||
Logger.warn(
|
try {
|
||||||
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
|
x.cancel();
|
||||||
id,
|
} catch (error: any) {
|
||||||
error?.message || error,
|
Logger.warn(
|
||||||
);
|
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
|
||||||
}
|
id,
|
||||||
});
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
scheduleStacks.delete(id);
|
scheduleStacks.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||||
|
import os from 'os';
|
||||||
|
import { AddCronResponse, SetConcurrencyRequest } from '../protos/cron';
|
||||||
|
import taskLimit from '../shared/pLimit';
|
||||||
|
|
||||||
|
export async function setConcurrency(
|
||||||
|
call: ServerUnaryCall<SetConcurrencyRequest, AddCronResponse>,
|
||||||
|
callback: sendUnaryData<AddCronResponse>,
|
||||||
|
) {
|
||||||
|
const limit = call.request.concurrency;
|
||||||
|
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||||
|
callback(
|
||||||
|
Object.assign(new Error('Invalid concurrency'), {
|
||||||
|
code: status.INVALID_ARGUMENT,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||||
|
callback(null, {});
|
||||||
|
} catch (error) {
|
||||||
|
callback(error as Error, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,12 @@ export default class ConfigService {
|
|||||||
|
|
||||||
public async getFile(filePath: string, res: Response) {
|
public async getFile(filePath: string, res: Response) {
|
||||||
let content = '';
|
let content = '';
|
||||||
if (!filePath) {
|
if (!filePath || filePath.startsWith('data/scripts/')) {
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
const scriptFile = filePath.startsWith('data/scripts/');
|
|
||||||
const resolved = resolveFileAccess(
|
const resolved = resolveFileAccess(
|
||||||
scriptFile ? config.scriptPath : config.configPath,
|
config.configPath,
|
||||||
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
[filePath],
|
||||||
config.blackFileList,
|
config.blackFileList,
|
||||||
);
|
);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
|
|||||||
+29
-16
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { getInvalidCronSchedules } from '../shared/cronSchedule';
|
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
|
||||||
import {
|
import {
|
||||||
withSchedulerMutation,
|
withSchedulerMutation,
|
||||||
schedulerRegistrationError,
|
schedulerRegistrationError,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
import { resolveLogPath } from '../shared/logPath';
|
||||||
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||||
import {
|
import {
|
||||||
RunningInstanceModel,
|
RunningInstanceModel,
|
||||||
@@ -14,7 +15,6 @@ import {
|
|||||||
} from '../data/runningInstance';
|
} from '../data/runningInstance';
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import CronExpressionParser from 'cron-parser';
|
|
||||||
import {
|
import {
|
||||||
getFileContentByName,
|
getFileContentByName,
|
||||||
fileExist,
|
fileExist,
|
||||||
@@ -47,12 +47,20 @@ export default class CronService {
|
|||||||
|
|
||||||
private isNodeCron(cron: Crontab) {
|
private isNodeCron(cron: Crontab) {
|
||||||
const { schedule, extra_schedules } = cron;
|
const { schedule, extra_schedules } = cron;
|
||||||
|
const fields = schedule?.trim().split(/\s+/) || [];
|
||||||
// System crontab only receives portable numeric five-field expressions.
|
// System crontab only receives portable numeric five-field expressions.
|
||||||
// Extended syntax, macros and legacy shorthand belong to node-schedule.
|
// Extended syntax, macros and legacy shorthand use the Node scheduler.
|
||||||
return (
|
return (
|
||||||
schedule?.trim().split(/\s+/).length !== 5 ||
|
fields.length !== 5 ||
|
||||||
/[^\d\s*,/\-]/.test(schedule || '') ||
|
/[^\d\s*,/\-]/.test(schedule || '') ||
|
||||||
Boolean(extra_schedules?.length)
|
Boolean(extra_schedules?.length) ||
|
||||||
|
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
|
||||||
|
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
|
||||||
|
fields[4].includes('7') ||
|
||||||
|
// BusyBox steps DOM from zero; its full-range day/week wildcard
|
||||||
|
// handling also differs from the legacy Node parser's OR semantics.
|
||||||
|
fields[2].includes('/') ||
|
||||||
|
(fields[2] !== '*' && fields[4] !== '*')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +247,9 @@ export default class CronService {
|
|||||||
last_execution_time: number;
|
last_execution_time: number;
|
||||||
exit_code?: number;
|
exit_code?: number;
|
||||||
}) {
|
}) {
|
||||||
|
if (log_path && log_path !== '/dev/null') {
|
||||||
|
resolveLogPath(config.logPath, log_path);
|
||||||
|
}
|
||||||
let options: any = {
|
let options: any = {
|
||||||
status,
|
status,
|
||||||
pid,
|
pid,
|
||||||
@@ -630,7 +641,7 @@ export default class CronService {
|
|||||||
stopped.add(pid);
|
stopped.add(pid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][停止任务失败] PID: %s, 错误: %s',
|
t('[panel][停止任务失败] PID: %s, 错误: %s'),
|
||||||
pid,
|
pid,
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
@@ -684,7 +695,7 @@ export default class CronService {
|
|||||||
await killTask(instance.pid);
|
await killTask(instance.pid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,20 +791,20 @@ export default class CronService {
|
|||||||
const result = await completed;
|
const result = await completed;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
id,
|
id,
|
||||||
result.error.message
|
result.error.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
id,
|
id,
|
||||||
result.code
|
result.code
|
||||||
);
|
);
|
||||||
return { ...cron, pid: cp.pid, ...result } as any;
|
return { ...cron, pid: cp.pid, ...result } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
|
||||||
cronId,
|
cronId,
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
@@ -802,7 +813,7 @@ export default class CronService {
|
|||||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][关闭任务日志失败] %s',
|
t('[panel][关闭任务日志失败] %s'),
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -834,7 +845,7 @@ export default class CronService {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][清理任务状态失败] %s',
|
t('[panel][清理任务状态失败] %s'),
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -918,7 +929,9 @@ export default class CronService {
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
const absolutePath = doc.log_path
|
||||||
|
? resolveLogPath(config.logPath, doc.log_path)
|
||||||
|
: '';
|
||||||
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
||||||
if (logFileExist) {
|
if (logFileExist) {
|
||||||
const chunk = await readLogChunk(`${absolutePath}`, options);
|
const chunk = await readLogChunk(`${absolutePath}`, options);
|
||||||
@@ -948,12 +961,12 @@ export default class CronService {
|
|||||||
|
|
||||||
public async logs(id: number) {
|
public async logs(id: number) {
|
||||||
const doc = await this.getDb({ id });
|
const doc = await this.getDb({ id });
|
||||||
if (!doc || !doc.log_path) {
|
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||||
const dir = path.resolve(config.logPath, relativeDir);
|
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||||
const dirExist = await fileExist(dir);
|
const dirExist = await fileExist(dir);
|
||||||
if (dirExist) {
|
if (dirExist) {
|
||||||
let files = await fs.readdir(dir);
|
let files = await fs.readdir(dir);
|
||||||
@@ -1057,7 +1070,7 @@ export default class CronService {
|
|||||||
if (
|
if (
|
||||||
command &&
|
command &&
|
||||||
schedule &&
|
schedule &&
|
||||||
CronExpressionParser.parse(schedule).hasNext()
|
isValidCronSchedule(schedule)
|
||||||
) {
|
) {
|
||||||
const name = namePrefix + '_' + index;
|
const name = namePrefix + '_' + index;
|
||||||
|
|
||||||
|
|||||||
+69
-24
@@ -20,6 +20,7 @@ import {
|
|||||||
getInstallCommand,
|
getInstallCommand,
|
||||||
getUninstallCommand,
|
getUninstallCommand,
|
||||||
getGetCommand,
|
getGetCommand,
|
||||||
|
isPythonSourceDependency,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
@@ -32,10 +33,11 @@ export default class DependenceService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public async create(payloads: Dependence[]): Promise<Dependence[]> {
|
public async create(payloads: Dependence[]): Promise<Dependence[]> {
|
||||||
const tabs = payloads.map((x) => {
|
const tabs = payloads.map((x) => {
|
||||||
|
getInstallCommand(x.type, x.name);
|
||||||
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
|
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
|
||||||
return tab;
|
return tab;
|
||||||
});
|
});
|
||||||
@@ -59,6 +61,7 @@ export default class DependenceService {
|
|||||||
...other,
|
...other,
|
||||||
status: DependenceStatus.queued,
|
status: DependenceStatus.queued,
|
||||||
});
|
});
|
||||||
|
getInstallCommand(tab.type, tab.name);
|
||||||
const newDoc = await this.updateDb(tab);
|
const newDoc = await this.updateDb(tab);
|
||||||
this.installDependenceOneByOne([newDoc]);
|
this.installDependenceOneByOne([newDoc]);
|
||||||
return newDoc;
|
return newDoc;
|
||||||
@@ -74,21 +77,25 @@ export default class DependenceService {
|
|||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
taskLimit.removeQueuedDependency(doc);
|
taskLimit.removeQueuedDependency(doc);
|
||||||
}
|
}
|
||||||
const unInstalledDeps = docs.filter(
|
|
||||||
(x) => x.status !== DependenceStatus.installed,
|
|
||||||
);
|
|
||||||
const installedDeps = docs.filter(
|
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!));
|
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
||||||
|
|
||||||
if (installedDeps.length) {
|
if (installedDeps.length) {
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
{ status: DependenceStatus.queued, log: [] },
|
{ 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;
|
return docs;
|
||||||
}
|
}
|
||||||
@@ -154,9 +161,15 @@ export default class DependenceService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const checks = await concurrentRun(
|
const checks = await concurrentRun(
|
||||||
docs.map((doc) => async () => {
|
docs
|
||||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
.filter(
|
||||||
}),
|
(doc) =>
|
||||||
|
doc.type !== DependenceTypes.python3 ||
|
||||||
|
!isPythonSourceDependency(doc.name),
|
||||||
|
)
|
||||||
|
.map((doc) => async () => {
|
||||||
|
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||||
|
}),
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
const missingIds = (checks || []).filter(
|
const missingIds = (checks || []).filter(
|
||||||
@@ -175,7 +188,10 @@ export default class DependenceService {
|
|||||||
let depName = dependency.name.trim();
|
let depName = dependency.name.trim();
|
||||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||||
let depVersion = '';
|
let depVersion = '';
|
||||||
if (depName.includes(depVersionStr)) {
|
if (
|
||||||
|
dependency.type !== DependenceTypes.python3 &&
|
||||||
|
depName.includes(depVersionStr)
|
||||||
|
) {
|
||||||
const symbolRegx = new RegExp(
|
const symbolRegx = new RegExp(
|
||||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||||
);
|
);
|
||||||
@@ -296,6 +312,26 @@ export default class DependenceService {
|
|||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
) {
|
) {
|
||||||
return taskLimit.runDependeny(dependency, () => {
|
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) => {
|
return new Promise(async (resolve) => {
|
||||||
if (taskLimit.firstDependencyId !== dependency.id) {
|
if (taskLimit.firstDependencyId !== dependency.id) {
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
@@ -345,9 +381,7 @@ export default class DependenceService {
|
|||||||
: DependenceStatus.removing;
|
: DependenceStatus.removing;
|
||||||
await DependenceModel.update({ status }, { where: { id: depIds } });
|
await DependenceModel.update({ status }, { where: { id: depIds } });
|
||||||
|
|
||||||
let command = isInstall
|
let command = validatedCommand;
|
||||||
? getInstallCommand(dependency.type, depName)
|
|
||||||
: getUninstallCommand(dependency.type, depName);
|
|
||||||
if (isLinuxDependence) {
|
if (isLinuxDependence) {
|
||||||
command = isInstall
|
command = isInstall
|
||||||
? `${linuxCommand.install} ${depName.trim()}`
|
? `${linuxCommand.install} ${depName.trim()}`
|
||||||
@@ -370,7 +404,13 @@ export default class DependenceService {
|
|||||||
this.updateLog(depIds, message);
|
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);
|
let getCommand = getGetCommand(dependency.type, depName);
|
||||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||||
if (isLinuxDependence) {
|
if (isLinuxDependence) {
|
||||||
@@ -378,7 +418,7 @@ export default class DependenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let depVersion = '';
|
let depVersion = '';
|
||||||
if (depName.includes(depVersionStr)) {
|
if (!isPythonDependence && depName.includes(depVersionStr)) {
|
||||||
const symbolRegx = new RegExp(
|
const symbolRegx = new RegExp(
|
||||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||||
);
|
);
|
||||||
@@ -470,15 +510,20 @@ export default class DependenceService {
|
|||||||
|
|
||||||
const message =
|
const message =
|
||||||
'\n' +
|
'\n' +
|
||||||
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
|
tf(
|
||||||
actionText,
|
'依赖%s%s,结束时间 %s,耗时 %s 秒',
|
||||||
resultText,
|
actionText,
|
||||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
resultText,
|
||||||
String(endTime.diff(startTime, 'second')),
|
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
);
|
String(endTime.diff(startTime, 'second')),
|
||||||
|
);
|
||||||
const exitStatus = isSucceed
|
const exitStatus = isSucceed
|
||||||
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
|
? isInstall
|
||||||
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
|
? DependenceStatus.installed
|
||||||
|
: DependenceStatus.removed
|
||||||
|
: isInstall
|
||||||
|
? DependenceStatus.installFailed
|
||||||
|
: DependenceStatus.removeFailed;
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: socketMessageType,
|
type: socketMessageType,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -262,10 +262,9 @@ export default class EnvService {
|
|||||||
.map((x) => x.value)
|
.map((x) => x.value)
|
||||||
.join('&')
|
.join('&')
|
||||||
.replace(/\\/g, '\\\\')}`;
|
.replace(/\\/g, '\\\\')}`;
|
||||||
js_env_string += `process.env.${key}=\`${_env_value.replace(
|
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
|
||||||
/\`/g,
|
group.map((x) => x.value).join('&'),
|
||||||
'\\`',
|
)};\n`;
|
||||||
)}\`;\n`;
|
|
||||||
py_env_string += `os.environ['${key}']='''${_env_value.replace(
|
py_env_string += `os.environ['${key}']='''${_env_value.replace(
|
||||||
/\'/g,
|
/\'/g,
|
||||||
"\\'",
|
"\\'",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
|
|||||||
import { ApiService } from '../protos/api';
|
import { ApiService } from '../protos/api';
|
||||||
import { addCron } from '../schedule/addCron';
|
import { addCron } from '../schedule/addCron';
|
||||||
import { delCron } from '../schedule/delCron';
|
import { delCron } from '../schedule/delCron';
|
||||||
|
import { setConcurrency } from '../schedule/setConcurrency';
|
||||||
import { check } from '../schedule/health';
|
import { check } from '../schedule/health';
|
||||||
import * as Api from '../schedule/api';
|
import * as Api from '../schedule/api';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
@@ -27,7 +28,7 @@ export class GrpcServerService {
|
|||||||
async initialize() {
|
async initialize() {
|
||||||
try {
|
try {
|
||||||
this.server.addService(HealthService, { check });
|
this.server.addService(HealthService, { check });
|
||||||
this.server.addService(CronService, { addCron, delCron });
|
this.server.addService(CronService, { addCron, delCron, setConcurrency });
|
||||||
this.server.addService(ApiService, Api);
|
this.server.addService(ApiService, Api);
|
||||||
|
|
||||||
const tlsConfig = await initGrpcCerts();
|
const tlsConfig = await initGrpcCerts();
|
||||||
@@ -49,13 +50,13 @@ export class GrpcServerService {
|
|||||||
for (const host of hostsToTry) {
|
for (const host of hostsToTry) {
|
||||||
try {
|
try {
|
||||||
const address = this.formatGrpcAddress(host, grpcPort);
|
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}`);
|
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||||
metricsService.record('grpc_service_start', 1, {
|
metricsService.record('grpc_service_start', 1, {
|
||||||
port: grpcPort.toString(),
|
port: grpcPort.toString(),
|
||||||
host
|
host
|
||||||
});
|
});
|
||||||
return grpcPort;
|
return boundPort;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err as Error;
|
lastError = err as Error;
|
||||||
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
|
|||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import cronClient from '../schedule/client';
|
import cronClient from '../schedule/client';
|
||||||
import { HttpServerService } from './http';
|
import { HttpServerService } from './http';
|
||||||
|
import { runtimeBuild } from '../shared/runtimeBuild';
|
||||||
|
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
|
build: typeof runtimeBuild;
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
services: {
|
services: {
|
||||||
http: boolean;
|
http: boolean;
|
||||||
@@ -22,12 +24,11 @@ interface HealthStatus {
|
|||||||
export class HealthService {
|
export class HealthService {
|
||||||
private startTime = Date.now();
|
private startTime = Date.now();
|
||||||
|
|
||||||
constructor(
|
constructor(private httpServerService: HttpServerService) {}
|
||||||
private httpServerService: HttpServerService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async check(): Promise<HealthStatus> {
|
async check(): Promise<HealthStatus> {
|
||||||
const status: HealthStatus = {
|
const status: HealthStatus = {
|
||||||
|
build: runtimeBuild,
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
services: {
|
services: {
|
||||||
http: true,
|
http: true,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { resolveFileAccess } from '../shared/fileAccess';
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
import path from 'path';
|
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
@@ -9,10 +8,6 @@ export default class LogService {
|
|||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
return resolveFileAccess(
|
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
|
||||||
config.logPath,
|
|
||||||
[filePath || '', fileName],
|
|
||||||
config.blackFileList,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { performance } from 'perf_hooks';
|
import { performance } from 'perf_hooks';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ export class MetricsService {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
metrics: this.getMetrics(),
|
metrics: this.getMetrics(),
|
||||||
};
|
};
|
||||||
Logger.info('性能指标报告:', report);
|
Logger.info(t('性能指标报告:'), report);
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-25
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
@@ -55,27 +56,33 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
notificationInfo?: NotificationInfo,
|
notificationInfo?: NotificationInfo,
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
let { type, ...rest } = await this.userService.getNotificationMode();
|
let type: string | undefined;
|
||||||
if (notificationInfo?.type) {
|
let success = false;
|
||||||
type = notificationInfo?.type;
|
try {
|
||||||
}
|
const saved = await this.userService.getNotificationMode();
|
||||||
if (type) {
|
type = notificationInfo?.type || saved.type;
|
||||||
this.title = title;
|
if (type) {
|
||||||
this.content = content;
|
this.title = title;
|
||||||
let params = rest;
|
this.content = content;
|
||||||
if (notificationInfo) {
|
const { type: _, ...overrides } = notificationInfo || {};
|
||||||
const { type: _, ...others } = notificationInfo;
|
const { type: _saved, ...rest } = saved;
|
||||||
params = { ...rest, ...others };
|
this.params = { ...rest, ...overrides };
|
||||||
}
|
try {
|
||||||
this.params = params;
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
} catch (error) {
|
||||||
try {
|
console.error(error);
|
||||||
return await notificationModeAction?.call(this);
|
}
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async testNotify(
|
public async testNotify(
|
||||||
@@ -84,14 +91,23 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
) {
|
) {
|
||||||
const { type, ...rest } = info;
|
const { type, ...rest } = info;
|
||||||
if (type) {
|
if (!type) return true;
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.params = rest;
|
this.params = rest;
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
return await notificationModeAction?.call(this);
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMailRecipients(value?: string) {
|
private parseMailRecipients(value?: string) {
|
||||||
@@ -921,7 +937,8 @@ export default class NotificationService {
|
|||||||
private async openiLink() {
|
private async openiLink() {
|
||||||
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
||||||
this.params;
|
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 url = `${baseUrl}/bot/v1/message/send`;
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -974,5 +991,4 @@ export default class NotificationService {
|
|||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-34
@@ -6,27 +6,77 @@ import { v4 as uuidV4 } from 'uuid';
|
|||||||
import sequelize, { Op } from 'sequelize';
|
import sequelize, { Op } from 'sequelize';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
|
import { lock } from 'proper-lockfile';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class OpenService {
|
export default class OpenService {
|
||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
|
// Every database mutation and full authentication-cache publication shares
|
||||||
|
// this cross-process lock. Lock a separate marker, not token.json itself.
|
||||||
|
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const target = path.join(config.configPath, '.apps-state');
|
||||||
|
const release = await lock(target, {
|
||||||
|
realpath: false,
|
||||||
|
stale: 10_000,
|
||||||
|
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initializeSystemApp(): Promise<void> {
|
||||||
|
await this.withAppsLock(async () => {
|
||||||
|
const doc = await AppModel.findOne({ where: { name: 'system' } });
|
||||||
|
const app = doc?.get({ plain: true });
|
||||||
|
if (!app) {
|
||||||
|
await this.insert({
|
||||||
|
name: 'system',
|
||||||
|
scopes: ['crons', 'system', 'dashboard'],
|
||||||
|
client_id: createRandomString(12, 12),
|
||||||
|
client_secret: createRandomString(24, 24),
|
||||||
|
});
|
||||||
|
} else if (!app.scopes.includes('dashboard')) {
|
||||||
|
await AppModel.update(
|
||||||
|
{ scopes: [...app.scopes, 'dashboard'] },
|
||||||
|
{ where: { id: app.id } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await shareStore.updateApps(await this.find({}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshApps(): Promise<void> {
|
||||||
|
await this.withAppsLock(async () => {
|
||||||
|
await shareStore.updateApps(await this.find({}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async findApps(): Promise<App[] | null> {
|
public async findApps(): Promise<App[] | null> {
|
||||||
const docs = await this.find({});
|
const docs = await this.find({});
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create(payload: App): Promise<App> {
|
public async create(payload: App): Promise<App> {
|
||||||
const tab = { ...payload };
|
return this.withAppsLock(async () => {
|
||||||
tab.client_id = createRandomString(12, 12);
|
const tab = { ...payload };
|
||||||
tab.client_secret = createRandomString(24, 24);
|
tab.client_id = createRandomString(12, 12);
|
||||||
const doc = await this.insert(tab);
|
tab.client_secret = createRandomString(24, 24);
|
||||||
const apps = await this.find({});
|
const doc = await this.insert(tab);
|
||||||
await shareStore.updateApps(apps);
|
const apps = await this.find({});
|
||||||
return { ...doc, tokens: [] };
|
await shareStore.updateApps(apps);
|
||||||
|
return { ...doc, tokens: [] };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async insert(payload: App): Promise<App> {
|
private async insert(payload: App): Promise<App> {
|
||||||
const doc = await AppModel.create(payload, { returning: true });
|
const doc = await AppModel.create(payload, { returning: true });
|
||||||
return doc.get({ plain: true });
|
return doc.get({ plain: true });
|
||||||
}
|
}
|
||||||
@@ -41,10 +91,12 @@ export default class OpenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async updateDb(payload: Partial<App>): Promise<App> {
|
private async updateDb(payload: Partial<App>): Promise<App> {
|
||||||
await AppModel.update(payload, { where: { id: payload.id } });
|
return this.withAppsLock(async () => {
|
||||||
const apps = await this.find({});
|
await AppModel.update(payload, { where: { id: payload.id } });
|
||||||
await shareStore.updateApps(apps);
|
const apps = await this.find({});
|
||||||
return apps?.find((x) => x.id === payload.id) as App;
|
await shareStore.updateApps(apps);
|
||||||
|
return apps?.find((x) => x.id === payload.id) as App;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDb(query: Record<string, any>): Promise<App> {
|
public async getDb(query: Record<string, any>): Promise<App> {
|
||||||
@@ -56,9 +108,11 @@ export default class OpenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async remove(ids: number[]) {
|
public async remove(ids: number[]) {
|
||||||
await AppModel.destroy({ where: { id: ids } });
|
return this.withAppsLock(async () => {
|
||||||
const apps = await this.find({});
|
await AppModel.destroy({ where: { id: ids } });
|
||||||
await shareStore.updateApps(apps);
|
const apps = await this.find({});
|
||||||
|
await shareStore.updateApps(apps);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resetSecret(id: number): Promise<App> {
|
public async resetSecret(id: number): Promise<App> {
|
||||||
@@ -112,7 +166,15 @@ export default class OpenService {
|
|||||||
return docs.map((x) => x.get({ plain: true }));
|
return docs.map((x) => x.get({ plain: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async authToken({
|
public async authToken(credentials: {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return this.withAppsLock(() => this.issueToken(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds withAppsLock through database update and cache publication.
|
||||||
|
private async issueToken({
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret,
|
||||||
}: {
|
}: {
|
||||||
@@ -156,27 +218,47 @@ export default class OpenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async generateSystemToken(): Promise<{
|
public async generateSystemToken(renew = false): Promise<{
|
||||||
value: string;
|
value: string;
|
||||||
expiration: number;
|
expiration: number;
|
||||||
}> {
|
}> {
|
||||||
const apps = await shareStore.getApps();
|
return this.withAppsLock(async () => {
|
||||||
const systemApp = apps?.find((x) => x.name === 'system');
|
// Read after acquiring the lock; a concurrent issuer may have renewed.
|
||||||
if (!systemApp) {
|
const systemApp = await this.getDb({ name: 'system' });
|
||||||
throw new Error('system app not found');
|
const now = Math.round(Date.now() / 1000);
|
||||||
}
|
let appToken = systemApp.tokens
|
||||||
const now = Math.round(Date.now() / 1000);
|
?.filter((token) => token.expiration > now)
|
||||||
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
|
.sort((left, right) => right.expiration - left.expiration)[0];
|
||||||
if (currentToken) {
|
if (renew || !appToken) {
|
||||||
return currentToken;
|
const { data } = await this.issueToken({
|
||||||
}
|
client_id: systemApp.client_id,
|
||||||
const { data } = await this.authToken({
|
client_secret: systemApp.client_secret,
|
||||||
client_id: systemApp.client_id,
|
});
|
||||||
client_secret: systemApp.client_secret,
|
appToken = { value: data.token, expiration: data.expiration };
|
||||||
|
} else {
|
||||||
|
// Recover a prior database commit whose cache publication failed.
|
||||||
|
await shareStore.updateApps(await this.find({}));
|
||||||
|
}
|
||||||
|
const tokenFile = path.join(config.configPath, 'token.json');
|
||||||
|
const temporaryFile = `${tokenFile}.${process.pid}.tmp`;
|
||||||
|
const mode = await stat(tokenFile).then(
|
||||||
|
(file) => file.mode & 0o777,
|
||||||
|
(error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'ENOENT') return 0o600;
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, {
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
await chmod(temporaryFile, mode);
|
||||||
|
// Shell readers see a complete old or new document, never a partial one.
|
||||||
|
await rename(temporaryFile, tokenFile);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryFile, { force: true });
|
||||||
|
}
|
||||||
|
return appToken;
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
value: data.token,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-25
@@ -1,6 +1,7 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import nodeSchedule from 'node-schedule';
|
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||||
import { ChildProcessWithoutNullStreams } from 'child_process';
|
import { ChildProcessWithoutNullStreams } from 'child_process';
|
||||||
import {
|
import {
|
||||||
ToadScheduler,
|
ToadScheduler,
|
||||||
@@ -11,7 +12,11 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess';
|
import {
|
||||||
|
observeChildProcess,
|
||||||
|
asError,
|
||||||
|
ProcessResult,
|
||||||
|
} from '../shared/childProcess';
|
||||||
|
|
||||||
export interface ScheduleTaskType {
|
export interface ScheduleTaskType {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -38,7 +43,7 @@ export interface TaskCallbacks {
|
|||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class ScheduleService {
|
export default class ScheduleService {
|
||||||
private scheduleStacks = new Map<string, nodeSchedule.Job>();
|
private scheduleStacks = new Map<string, CronJob>();
|
||||||
|
|
||||||
private intervalSchedule = new ToadScheduler();
|
private intervalSchedule = new ToadScheduler();
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ export default class ScheduleService {
|
|||||||
let cp: ChildProcessWithoutNullStreams | undefined;
|
let cp: ChildProcessWithoutNullStreams | undefined;
|
||||||
let result: ProcessResult = { code: null, signal: null };
|
let result: ProcessResult = { code: null, signal: null };
|
||||||
try {
|
try {
|
||||||
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
|
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
|
||||||
await callbacks.onBefore?.(startTime);
|
await callbacks.onBefore?.(startTime);
|
||||||
cp = spawn(command, { shell: '/bin/bash' });
|
cp = spawn(command, { shell: '/bin/bash' });
|
||||||
const child = cp;
|
const child = cp;
|
||||||
@@ -99,7 +104,7 @@ export default class ScheduleService {
|
|||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
others.id,
|
others.id,
|
||||||
result.error.message,
|
result.error.message,
|
||||||
);
|
);
|
||||||
@@ -107,7 +112,7 @@ export default class ScheduleService {
|
|||||||
await callbacks.onError?.(result.error.message);
|
await callbacks.onError?.(result.error.message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][任务错误回调失败] %s',
|
t('[panel][任务错误回调失败] %s'),
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,12 +128,12 @@ export default class ScheduleService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.error ??= asError(error);
|
result.error ??= asError(error);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][任务结束回调失败] %s',
|
t('[panel][任务结束回调失败] %s'),
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
others.id,
|
others.id,
|
||||||
result.code,
|
result.code,
|
||||||
);
|
);
|
||||||
@@ -137,7 +142,7 @@ export default class ScheduleService {
|
|||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
// Queue/setup failures must not become unhandled rejections in detached callers.
|
// Queue/setup failures must not become unhandled rejections in detached callers.
|
||||||
rejectStart(asError(error));
|
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) };
|
return { ...others, code: null, signal: null, error: asError(error) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,25 +157,40 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
|
||||||
_id,
|
_id,
|
||||||
schedule,
|
schedule,
|
||||||
name,
|
name,
|
||||||
command,
|
command,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scheduleStacks.set(
|
if (schedule) {
|
||||||
_id,
|
try {
|
||||||
nodeSchedule.scheduleJob(_id, schedule, async () => {
|
const job = createCronJob(
|
||||||
this.runTask(command, callbacks, {
|
|
||||||
name,
|
|
||||||
schedule,
|
schedule,
|
||||||
command,
|
() =>
|
||||||
id: _id,
|
this.runTask(command, callbacks, {
|
||||||
runOrigin,
|
name,
|
||||||
});
|
schedule,
|
||||||
}),
|
command,
|
||||||
);
|
id: _id,
|
||||||
|
runOrigin,
|
||||||
|
}),
|
||||||
|
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
|
||||||
|
);
|
||||||
|
this.scheduleStacks.get(_id)?.cancel();
|
||||||
|
this.scheduleStacks.set(_id, job);
|
||||||
|
job.start();
|
||||||
|
} catch (error) {
|
||||||
|
// A persisted invalid subscription must not become an unhandled startup rejection.
|
||||||
|
this.logger.warn(
|
||||||
|
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
|
||||||
|
_id,
|
||||||
|
schedule,
|
||||||
|
asError(error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (runImmediately) {
|
if (runImmediately) {
|
||||||
this.runTask(command, callbacks, {
|
this.runTask(command, callbacks, {
|
||||||
@@ -185,7 +205,7 @@ export default class ScheduleService {
|
|||||||
|
|
||||||
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
|
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
|
||||||
if (this.scheduleStacks.has(_id)) {
|
if (this.scheduleStacks.has(_id)) {
|
||||||
this.scheduleStacks.get(_id)?.cancel();
|
this.scheduleStacks.get(_id)?.cancel();
|
||||||
this.scheduleStacks.delete(_id);
|
this.scheduleStacks.delete(_id);
|
||||||
@@ -200,7 +220,7 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
|
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
command,
|
command,
|
||||||
@@ -217,7 +237,7 @@ export default class ScheduleService {
|
|||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
|
||||||
command,
|
command,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -245,7 +265,7 @@ export default class ScheduleService {
|
|||||||
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
|
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export default class ScriptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
return resolveFileAccess(
|
return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
|
||||||
config.scriptPath,
|
|
||||||
[filePath || '', fileName],
|
|
||||||
config.blackFileList,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFile(filePath: string, fileName: string) {
|
public async getFile(filePath: string, fileName: string) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import config from '../config';
|
||||||
|
import { ScriptHistory } from '../shared/scriptHistory';
|
||||||
|
|
||||||
|
/** Panel configuration and lifecycle; the storage core stays independently testable. */
|
||||||
|
@Service()
|
||||||
|
export default class ScriptHistoryService extends ScriptHistory {
|
||||||
|
constructor() {
|
||||||
|
super(config.scriptPath, path.join(config.dataPath, 'script-history'));
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-21
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
@@ -8,10 +9,22 @@ import { formatUrl } from '../config/subscription';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { fileExist, rmPath } from '../config/util';
|
import { fileExist, rmPath } from '../config/util';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import {
|
||||||
|
getSubscriptionSshAlias,
|
||||||
|
resolveSubscriptionPath,
|
||||||
|
} from '../shared/subscriptionPath';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class SshKeyService {
|
export default class SshKeyService {
|
||||||
private homedir = os.homedir();
|
// OpenSSH uses the effective user's passwd entry, not the HOME override.
|
||||||
|
private get homedir(): string {
|
||||||
|
try {
|
||||||
|
return os.userInfo().homedir;
|
||||||
|
} catch {
|
||||||
|
// Custom container UIDs may have no passwd entry. Keep startup working.
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
}
|
||||||
private sshPath = config.sshdPath;
|
private sshPath = config.sshdPath;
|
||||||
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
|
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
|
||||||
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
|
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
|
||||||
@@ -21,6 +34,10 @@ export default class SshKeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async initSshConfigFile() {
|
private async initSshConfigFile() {
|
||||||
|
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
|
||||||
|
recursive: true,
|
||||||
|
mode: 0o700,
|
||||||
|
});
|
||||||
let config = '';
|
let config = '';
|
||||||
const _exist = await fileExist(this.sshConfigFilePath);
|
const _exist = await fileExist(this.sshConfigFilePath);
|
||||||
if (_exist) {
|
if (_exist) {
|
||||||
@@ -42,22 +59,22 @@ export default class SshKeyService {
|
|||||||
key: string,
|
key: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.sshPath, alias);
|
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||||
try {
|
try {
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
} catch { }
|
} catch {}
|
||||||
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('生成私钥文件失败', error);
|
this.logger.error(t('生成私钥文件失败'), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removePrivateKeyFile(alias: string): Promise<void> {
|
private async removePrivateKeyFile(alias: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.sshPath, alias);
|
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('删除私钥文件失败', error);
|
this.logger.error(t('删除私钥文件失败'), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +83,26 @@ export default class SshKeyService {
|
|||||||
host: string,
|
host: string,
|
||||||
proxy?: 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 (host === 'github.com') {
|
if (host === 'github.com') {
|
||||||
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
||||||
}
|
}
|
||||||
const proxyStr = proxy
|
const proxyStr = proxy
|
||||||
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
||||||
: '';
|
: '';
|
||||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
|
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
|
||||||
this.sshPath,
|
this.sshPath,
|
||||||
alias,
|
alias,
|
||||||
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||||
config,
|
config,
|
||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
@@ -88,10 +113,10 @@ export default class SshKeyService {
|
|||||||
|
|
||||||
private async removeSshConfig(alias: string) {
|
private async removeSshConfig(alias: string) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.sshPath, `${alias}.config`);
|
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`删除ssh配置文件${alias}失败`, error);
|
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +126,7 @@ export default class SshKeyService {
|
|||||||
host: string,
|
host: string,
|
||||||
proxy?: string,
|
proxy?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
alias = getSubscriptionSshAlias(alias);
|
||||||
await this.generatePrivateKeyFile(alias, key);
|
await this.generatePrivateKeyFile(alias, key);
|
||||||
await this.generateSingleSshConfig(alias, host, proxy);
|
await this.generateSingleSshConfig(alias, host, proxy);
|
||||||
}
|
}
|
||||||
@@ -110,6 +136,7 @@ export default class SshKeyService {
|
|||||||
host: string,
|
host: string,
|
||||||
proxy?: string,
|
proxy?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
alias = getSubscriptionSshAlias(alias);
|
||||||
await this.removePrivateKeyFile(alias);
|
await this.removePrivateKeyFile(alias);
|
||||||
await this.removeSshConfig(alias);
|
await this.removeSshConfig(alias);
|
||||||
}
|
}
|
||||||
@@ -117,15 +144,19 @@ export default class SshKeyService {
|
|||||||
public async setSshConfig(docs: Subscription[]) {
|
public async setSshConfig(docs: Subscription[]) {
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
|
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
|
||||||
const { alias, proxy } = doc;
|
try {
|
||||||
const { host } = formatUrl(doc);
|
const alias = getSubscriptionSshAlias(doc.alias);
|
||||||
await this.removePrivateKeyFile(alias);
|
const { host } = formatUrl(doc);
|
||||||
await this.removeSshConfig(alias);
|
await this.removePrivateKeyFile(alias);
|
||||||
await this.generatePrivateKeyFile(
|
await this.removeSshConfig(alias);
|
||||||
alias,
|
await this.generatePrivateKeyFile(
|
||||||
(doc.pull_option as any).private_key,
|
alias,
|
||||||
);
|
(doc.pull_option as any).private_key,
|
||||||
await this.generateSingleSshConfig(alias, host, proxy);
|
);
|
||||||
|
await this.generateSingleSshConfig(alias, host, doc.proxy);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,12 +175,12 @@ export default class SshKeyService {
|
|||||||
|
|
||||||
private async generateGlobalSshConfig(alias: string) {
|
private async generateGlobalSshConfig(alias: string) {
|
||||||
// Create a config that matches all hosts, making this key globally available
|
// Create a config that matches all hosts, making this key globally available
|
||||||
const config = `Host *\n IdentityFile ${path.join(
|
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
|
||||||
this.sshPath,
|
this.sshPath,
|
||||||
alias,
|
alias,
|
||||||
)}\n StrictHostKeyChecking no\n`;
|
)}\n StrictHostKeyChecking no\n`;
|
||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||||
config,
|
config,
|
||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
|
|||||||
+107
-32
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { FindOptions, Op } from 'sequelize';
|
import { FindOptions, Op } from 'sequelize';
|
||||||
import path, { join } from 'path';
|
import path from 'path';
|
||||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||||
import SockService from './sock';
|
import SockService from './sock';
|
||||||
@@ -30,6 +30,14 @@ import dayjs from 'dayjs';
|
|||||||
import { LOG_END_SYMBOL } from '../config/const';
|
import { LOG_END_SYMBOL } from '../config/const';
|
||||||
import { formatCommand, formatUrl } from '../config/subscription';
|
import { formatCommand, formatUrl } from '../config/subscription';
|
||||||
import { CrontabModel } from '../data/cron';
|
import { CrontabModel } from '../data/cron';
|
||||||
|
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||||
|
import { resolveLogPath } from '../shared/logPath';
|
||||||
|
import {
|
||||||
|
getSubscriptionStorageCandidates,
|
||||||
|
isSubscriptionStorageName,
|
||||||
|
resolveSubscriptionStoragePath,
|
||||||
|
} from '../shared/subscriptionStorage';
|
||||||
|
import { withSubscriptionMutation } from '../shared/subscriptionMutationLock';
|
||||||
import CrontabService from './cron';
|
import CrontabService from './cron';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
import { logStreamManager } from '../shared/logStreamManager';
|
import { logStreamManager } from '../shared/logStreamManager';
|
||||||
@@ -88,9 +96,11 @@ export default class SubscriptionService {
|
|||||||
needCreate = true,
|
needCreate = true,
|
||||||
runImmediately = false,
|
runImmediately = false,
|
||||||
) {
|
) {
|
||||||
const { url } = formatUrl(doc);
|
// Cancelling by ID must also work for invalid historical subscriptions.
|
||||||
|
if (needCreate) {
|
||||||
doc.command = formatCommand(doc, url as string);
|
const { url } = formatUrl(doc);
|
||||||
|
doc.command = formatCommand(doc, url as string);
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.schedule_type === 'crontab') {
|
if (doc.schedule_type === 'crontab') {
|
||||||
this.scheduleService.cancelCronTask(doc as any);
|
this.scheduleService.cancelCronTask(doc as any);
|
||||||
@@ -222,11 +232,14 @@ export default class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async create(payload: Subscription): Promise<Subscription> {
|
public async create(payload: Subscription): Promise<Subscription> {
|
||||||
const tab = new Subscription(payload);
|
assertSubscriptionAlias(payload.alias);
|
||||||
const doc = await this.insert(tab);
|
return withSubscriptionMutation(async () => {
|
||||||
await this.handleTask(doc.get({ plain: true }));
|
const tab = new Subscription(payload);
|
||||||
await this.setSshConfig();
|
const doc = await this.insert(tab);
|
||||||
return doc;
|
await this.handleTask(doc.get({ plain: true }));
|
||||||
|
await this.setSshConfig();
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
|
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
|
||||||
@@ -234,12 +247,27 @@ export default class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async update(payload: Subscription): Promise<Subscription> {
|
public async update(payload: Subscription): Promise<Subscription> {
|
||||||
const doc = await this.getDb({ id: payload.id });
|
assertSubscriptionAlias(payload.alias);
|
||||||
const tab = new Subscription({ ...doc, ...payload });
|
return withSubscriptionMutation(async () => {
|
||||||
const newDoc = await this.updateDb(tab);
|
const doc = await this.getDb({ id: payload.id });
|
||||||
await this.handleTask(newDoc, !newDoc.is_disabled);
|
const tab = new Subscription({ ...doc, ...payload });
|
||||||
await this.setSshConfig();
|
const newDoc = await this.updateDb(tab);
|
||||||
return newDoc;
|
await this.handleTask(newDoc, !newDoc.is_disabled);
|
||||||
|
await this.removeSshConfigForSubscription(doc);
|
||||||
|
await this.setSshConfig();
|
||||||
|
return newDoc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeSshConfigForSubscription(doc: Subscription) {
|
||||||
|
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
|
||||||
|
// Invalid historical rows must remain deletable without deriving unsafe paths.
|
||||||
|
try {
|
||||||
|
assertSubscriptionAlias(doc.alias);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sshKeyService.removeSSHKey(doc.alias, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateDb(payload: Subscription): Promise<Subscription> {
|
public async updateDb(payload: Subscription): Promise<Subscription> {
|
||||||
@@ -262,6 +290,7 @@ export default class SubscriptionService {
|
|||||||
last_running_time: number;
|
last_running_time: number;
|
||||||
last_execution_time: number;
|
last_execution_time: number;
|
||||||
}) {
|
}) {
|
||||||
|
if (log_path) resolveLogPath(config.logPath, log_path);
|
||||||
const options: any = {
|
const options: any = {
|
||||||
status,
|
status,
|
||||||
pid,
|
pid,
|
||||||
@@ -279,25 +308,71 @@ export default class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async remove(ids: number[], query: { force?: boolean }) {
|
public async remove(ids: number[], query: { force?: boolean }) {
|
||||||
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
return withSubscriptionMutation(async () => {
|
||||||
for (const doc of docs) {
|
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
||||||
await this.handleTask(doc.get({ plain: true }), false);
|
const storagePaths: string[] = [];
|
||||||
}
|
const retainedPaths: string[] = [];
|
||||||
await SubscriptionModel.destroy({ where: { id: ids } });
|
const collectPaths = (
|
||||||
await this.setSshConfig();
|
doc: Subscription,
|
||||||
|
targets: string[],
|
||||||
if (query?.force === true) {
|
deleting = false,
|
||||||
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
|
) => {
|
||||||
if (crons?.length) {
|
for (const names of getSubscriptionStorageCandidates(doc)) {
|
||||||
await this.crontabService.remove(crons.map((x) => x.id!));
|
// A URL accepted by one engine can produce an invalid name in another.
|
||||||
|
// Validate each candidate independently, retaining the safe candidate.
|
||||||
|
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
|
||||||
|
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
|
||||||
|
const scriptPath = resolve(config.scriptPath, names.script);
|
||||||
|
if (scriptPath) targets.push(scriptPath);
|
||||||
|
if (names.repo) {
|
||||||
|
const repoPath = resolve(config.repoPath, names.repo);
|
||||||
|
if (repoPath) targets.push(repoPath);
|
||||||
|
}
|
||||||
|
if (names.raw) {
|
||||||
|
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
|
||||||
|
if (rawPath) targets.push(rawPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Validate all deletion targets before changing rows or removing any files.
|
||||||
|
if (query?.force === true) {
|
||||||
|
for (const doc of docs) {
|
||||||
|
assertSubscriptionAlias(doc.alias);
|
||||||
|
resolveSubscriptionStoragePath(config.scriptPath, doc.alias);
|
||||||
|
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
|
||||||
|
collectPaths(doc, storagePaths, true);
|
||||||
|
}
|
||||||
|
const remaining = await SubscriptionModel.findAll({ where: {} });
|
||||||
|
for (const doc of remaining) {
|
||||||
|
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
const filePath = join(config.scriptPath, doc.alias);
|
await this.handleTask(doc.get({ plain: true }), false);
|
||||||
const repoPath = join(config.repoPath, doc.alias);
|
|
||||||
await rmPath(filePath);
|
|
||||||
await rmPath(repoPath);
|
|
||||||
}
|
}
|
||||||
}
|
await SubscriptionModel.destroy({ where: { id: ids } });
|
||||||
|
for (const doc of docs) {
|
||||||
|
await this.removeSshConfigForSubscription(doc);
|
||||||
|
}
|
||||||
|
await this.setSshConfig();
|
||||||
|
|
||||||
|
if (query?.force === true) {
|
||||||
|
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
|
||||||
|
if (crons?.length) {
|
||||||
|
await this.crontabService.remove(crons.map((x) => x.id!));
|
||||||
|
}
|
||||||
|
for (const storagePath of new Set(storagePaths)) {
|
||||||
|
// rm is recursive: an ancestor must also be retained if a surviving
|
||||||
|
// subscription uses a nested branch, and vice versa.
|
||||||
|
const shared = retainedPaths.some((retained) =>
|
||||||
|
retained === storagePath ||
|
||||||
|
retained.startsWith(`${storagePath}${path.sep}`) ||
|
||||||
|
storagePath.startsWith(`${retained}${path.sep}`),
|
||||||
|
);
|
||||||
|
if (!shared) await rmPath(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDb(
|
public async getDb(
|
||||||
@@ -399,7 +474,7 @@ export default class SubscriptionService {
|
|||||||
|
|
||||||
if (doc.log_path) {
|
if (doc.log_path) {
|
||||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||||
const dir = path.resolve(config.logPath, relativeDir);
|
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||||
const _exist = await fileExist(dir);
|
const _exist = await fileExist(dir);
|
||||||
if (_exist) {
|
if (_exist) {
|
||||||
let files = await fs.readdir(dir);
|
let files = await fs.readdir(dir);
|
||||||
|
|||||||
+168
-79
@@ -1,7 +1,11 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { shellQuote } from '../shared/shellQuote';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { Agent, request } from 'undici';
|
import { request } from 'undici';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
@@ -29,8 +33,12 @@ import {
|
|||||||
SystemInstance,
|
SystemInstance,
|
||||||
SystemModel,
|
SystemModel,
|
||||||
SystemModelInfo,
|
SystemModelInfo,
|
||||||
|
NotifyStatus,
|
||||||
|
NotifyLogInfo,
|
||||||
} from '../data/system';
|
} from '../data/system';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
|
import cronClient from '../schedule/client';
|
||||||
|
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
|
||||||
import NotificationService from './notify';
|
import NotificationService from './notify';
|
||||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||||
import SockService from './sock';
|
import SockService from './sock';
|
||||||
@@ -48,7 +56,7 @@ export default class SystemService {
|
|||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private scheduleService: ScheduleService,
|
private scheduleService: ScheduleService,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public async getSystemConfig() {
|
public async getSystemConfig() {
|
||||||
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
||||||
@@ -119,15 +127,29 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async updateCronConcurrency(info: SystemModelInfo) {
|
public async updateCronConcurrency(info: SystemModelInfo) {
|
||||||
const oDoc = await this.getSystemConfig();
|
const limit = info.cronConcurrency ?? 0;
|
||||||
await this.updateAuthDb({
|
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||||
...oDoc,
|
throw Object.assign(new Error(t('参数错误')), { status: 400 });
|
||||||
info: { ...oDoc.info, ...info },
|
|
||||||
});
|
|
||||||
if (info.cronConcurrency) {
|
|
||||||
await taskLimit.setCustomLimit(info.cronConcurrency);
|
|
||||||
}
|
}
|
||||||
return { code: 200, data: info };
|
return withSchedulerMutation(async () => {
|
||||||
|
const oDoc = await this.getSystemConfig();
|
||||||
|
await this.updateAuthDb({
|
||||||
|
...oDoc,
|
||||||
|
info: { ...oDoc.info, ...info, cronConcurrency: limit },
|
||||||
|
});
|
||||||
|
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||||
|
await cronClient.setConcurrency(limit);
|
||||||
|
return { code: 200, data: info };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restoreCronConcurrency() {
|
||||||
|
await withSchedulerMutation(async () => {
|
||||||
|
const doc = await this.getSystemConfig();
|
||||||
|
const limit = doc.info?.cronConcurrency ?? 0;
|
||||||
|
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||||
|
await cronClient.setConcurrency(limit);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateDependenceProxy(info: SystemModelInfo) {
|
public async updateDependenceProxy(info: SystemModelInfo) {
|
||||||
@@ -139,7 +161,9 @@ export default class SystemService {
|
|||||||
if (info.dependenceProxy) {
|
if (info.dependenceProxy) {
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
config.dependenceProxyFile,
|
config.dependenceProxyFile,
|
||||||
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
|
`export http_proxy=${shellQuote(
|
||||||
|
info.dependenceProxy,
|
||||||
|
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await fs.promises.rm(config.dependenceProxyFile);
|
await fs.promises.rm(config.dependenceProxyFile);
|
||||||
@@ -155,7 +179,7 @@ export default class SystemService {
|
|||||||
});
|
});
|
||||||
let cmd = 'pnpm config delete registry';
|
let cmd = 'pnpm config delete registry';
|
||||||
if (info.nodeMirror) {
|
if (info.nodeMirror) {
|
||||||
cmd = `pnpm config set registry ${info.nodeMirror}`;
|
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
|
||||||
}
|
}
|
||||||
let command = `cd && ${cmd}`;
|
let command = `cd && ${cmd}`;
|
||||||
const docs = await DependenceModel.findAll({
|
const docs = await DependenceModel.findAll({
|
||||||
@@ -204,7 +228,7 @@ export default class SystemService {
|
|||||||
});
|
});
|
||||||
let cmd = 'pip config unset global.index-url';
|
let cmd = 'pip config unset global.index-url';
|
||||||
if (info.pythonMirror) {
|
if (info.pythonMirror) {
|
||||||
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
|
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
|
||||||
}
|
}
|
||||||
await promiseExec(cmd);
|
await promiseExec(cmd);
|
||||||
return { code: 200, data: info };
|
return { code: 200, data: info };
|
||||||
@@ -220,7 +244,6 @@ export default class SystemService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
||||||
let hasError = false;
|
|
||||||
this.scheduleService.runTask(
|
this.scheduleService.runTask(
|
||||||
command,
|
command,
|
||||||
{
|
{
|
||||||
@@ -228,14 +251,15 @@ export default class SystemService {
|
|||||||
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
|
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||||
res?.end();
|
res?.end();
|
||||||
},
|
},
|
||||||
onEnd: async () => {
|
onEnd: async (cp) => {
|
||||||
this.sockService.sendMessage({
|
this.sockService.sendMessage({
|
||||||
type: 'updateLinuxMirror',
|
type: 'updateLinuxMirror',
|
||||||
message: 'update linux mirror end',
|
message: 'update linux mirror end',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
});
|
});
|
||||||
onEnd?.();
|
onEnd?.();
|
||||||
if (!hasError) {
|
// Tools such as sudo and apt may write warnings to stderr on success.
|
||||||
|
if (cp?.exitCode === 0 && cp.signalCode === null) {
|
||||||
await this.updateAuthDb({
|
await this.updateAuthDb({
|
||||||
...oDoc,
|
...oDoc,
|
||||||
info: { ...oDoc.info, ...info },
|
info: { ...oDoc.info, ...info },
|
||||||
@@ -243,7 +267,6 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
hasError = true;
|
|
||||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||||
},
|
},
|
||||||
onLog: async (message: string) => {
|
onLog: async (message: string) => {
|
||||||
@@ -259,26 +282,24 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkUpdate() {
|
public async checkUpdate() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
try {
|
try {
|
||||||
const currentVersionContent = await parseVersion(config.versionFile);
|
const currentVersionContent = await parseVersion(config.versionFile);
|
||||||
|
|
||||||
let lastVersionContent;
|
const { body, statusCode } = await request(
|
||||||
try {
|
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||||
const { body } = await request(
|
{ signal: controller.signal },
|
||||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
);
|
||||||
{
|
const text = await body.text();
|
||||||
dispatcher: new Agent({
|
if (statusCode !== 200) throw new Error('Version request failed');
|
||||||
keepAliveTimeout: 30000,
|
const lastVersionContent = parseContentVersion(text);
|
||||||
keepAliveMaxTimeout: 30000,
|
if (
|
||||||
}),
|
!lastVersionContent ||
|
||||||
},
|
typeof lastVersionContent.version !== 'string' ||
|
||||||
);
|
!/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
|
||||||
const text = await body.text();
|
) {
|
||||||
lastVersionContent = parseContentVersion(text);
|
throw new Error('Invalid version response');
|
||||||
} catch (error) { }
|
|
||||||
|
|
||||||
if (!lastVersionContent) {
|
|
||||||
lastVersionContent = currentVersionContent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -296,8 +317,10 @@ export default class SystemService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
code: 400,
|
code: 400,
|
||||||
message: error.message,
|
message: t('检查更新失败,请检查网络后重试'),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,43 +344,52 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async updateSystem() {
|
public async updateSystem() {
|
||||||
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
|
return this.startUpgradeJob('download');
|
||||||
|
}
|
||||||
|
|
||||||
cp.stdout.on('data', (data) => {
|
private upgradeJobs() {
|
||||||
this.sockService.sendMessage({
|
return require(path.join(
|
||||||
type: 'updateSystemVersion',
|
config.rootPath,
|
||||||
message: data.toString(),
|
'cli/src/internal/maintenance/panelUpgrade.cjs',
|
||||||
});
|
));
|
||||||
});
|
}
|
||||||
|
|
||||||
cp.stderr.on('data', (data) => {
|
public async getUpgradeStatus() {
|
||||||
this.sockService.sendMessage({
|
return {
|
||||||
type: 'updateSystemVersion',
|
code: 200,
|
||||||
message: data.toString(),
|
data: await this.upgradeJobs().getStatus(config.tmpPath),
|
||||||
});
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
cp.on('error', (err) => {
|
private async startUpgradeJob(action: 'download' | 'apply') {
|
||||||
this.sockService.sendMessage({
|
try {
|
||||||
type: 'updateSystemVersion',
|
const data = await this.upgradeJobs().startJob(
|
||||||
message: JSON.stringify(err),
|
{
|
||||||
status: 'failed',
|
root: config.rootPath,
|
||||||
});
|
tmp: config.tmpPath,
|
||||||
});
|
env: {
|
||||||
|
...process.env,
|
||||||
cp.on('exit', (code) => {
|
dir_data: config.dataPath,
|
||||||
this.sockService.sendMessage({
|
dir_config: config.configPath,
|
||||||
type: 'updateSystemVersion',
|
QlPort: String(config.port),
|
||||||
message: '',
|
QlBaseUrl: config.baseUrl,
|
||||||
status: code === 0 ? 'success' : 'failed',
|
},
|
||||||
});
|
},
|
||||||
});
|
action,
|
||||||
|
);
|
||||||
return { code: 200 };
|
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') {
|
public async reloadSystem(target?: 'system' | 'data') {
|
||||||
const cmd = `real_time=true ql reload ${target || ''}`;
|
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, {
|
const cp = spawn(cmd, {
|
||||||
shell: '/bin/bash',
|
shell: '/bin/bash',
|
||||||
detached: true,
|
detached: true,
|
||||||
@@ -386,11 +418,13 @@ export default class SystemService {
|
|||||||
if (notificationInfo && typeString) {
|
if (notificationInfo && typeString) {
|
||||||
notificationInfo.type = typeString;
|
notificationInfo.type = typeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = await this.notificationService.notify(
|
const isSuccess = await this.notificationService.notify(
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
notificationInfo,
|
notificationInfo,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
return { code: 200, message: t('通知发送成功') };
|
return { code: 200, message: t('通知发送成功') };
|
||||||
} else {
|
} else {
|
||||||
@@ -398,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)) {
|
if (!command.startsWith(TASK_COMMAND)) {
|
||||||
command = `${TASK_COMMAND} ${command}`;
|
command = `${TASK_COMMAND} ${command}`;
|
||||||
}
|
}
|
||||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
|
||||||
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
this.scheduleService.runTask(
|
||||||
command,
|
`${logPathPrefix} real_time=true ${command}`,
|
||||||
id: command.replace(/ /g, '-'),
|
callback,
|
||||||
runOrigin: 'system',
|
{
|
||||||
});
|
command,
|
||||||
|
id: command.replace(/ /g, '-'),
|
||||||
|
runOrigin: 'system',
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||||
@@ -438,10 +492,35 @@ export default class SystemService {
|
|||||||
if (type && type.length) {
|
if (type && type.length) {
|
||||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||||
}
|
}
|
||||||
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
|
const allowed = new Set([
|
||||||
await promiseExec(
|
'db',
|
||||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
|
'upload',
|
||||||
} ${dataPaths.join(' ')}`,
|
'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('参数错误') });
|
||||||
|
}
|
||||||
|
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
|
||||||
|
// History is created lazily; old/fresh installations must still export.
|
||||||
|
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
|
||||||
|
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),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
res.download(config.dataTgzFile);
|
res.download(config.dataTgzFile);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -533,7 +612,17 @@ export default class SystemService {
|
|||||||
const result = await readDirs(config.systemLogPath, config.systemLogPath);
|
const result = await readDirs(config.systemLogPath, config.systemLogPath);
|
||||||
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
|
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
|
||||||
for (const log of logs) {
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +654,7 @@ export default class SystemService {
|
|||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
config.langEnvFile,
|
config.langEnvFile,
|
||||||
`export QL_LANG='${lang}'\n`,
|
`export QL_LANG=${shellQuote(lang)}\n`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
||||||
|
|||||||
+17
-14
@@ -33,9 +33,11 @@ import {
|
|||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '../shared/password';
|
} from '../shared/password';
|
||||||
import { serializeAuthMutation } from '../shared/authMutation';
|
import { serializeAuthMutation } from '../shared/authMutation';
|
||||||
|
import { LoginThrottle } from '../shared/loginThrottle';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class UserService {
|
export default class UserService {
|
||||||
|
private loginThrottle = new LoginThrottle();
|
||||||
@Inject((type) => NotificationService)
|
@Inject((type) => NotificationService)
|
||||||
private notificationService!: NotificationService;
|
private notificationService!: NotificationService;
|
||||||
|
|
||||||
@@ -106,11 +108,8 @@ export default class UserService {
|
|||||||
return { code: 403, message: t('该 IP 已被列入黑名单') };
|
return { code: 403, message: t('该 IP 已被列入黑名单') };
|
||||||
}
|
}
|
||||||
|
|
||||||
const retriesTime = Math.pow(3, retries) * 1000;
|
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||||
if (retries > 2 && timestamp - lastlogon < retriesTime) {
|
if (waitTime > 0) {
|
||||||
const waitTime = Math.ceil(
|
|
||||||
(retriesTime - (timestamp - lastlogon)) / 1000,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
code: 410,
|
code: 410,
|
||||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||||
@@ -133,6 +132,7 @@ export default class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (passwordMatches) {
|
if (passwordMatches) {
|
||||||
|
this.loginThrottle.reset(ip);
|
||||||
const data = createRandomString(50, 100);
|
const data = createRandomString(50, 100);
|
||||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||||
let token = jwt.sign({ data }, config.jwt.secret, {
|
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||||
@@ -173,7 +173,7 @@ export default class UserService {
|
|||||||
t('你于') +
|
t('你于') +
|
||||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
t('在') +
|
t('在') +
|
||||||
address +
|
t(address) +
|
||||||
' ' +
|
' ' +
|
||||||
req.platform +
|
req.platform +
|
||||||
t('端') +
|
t('端') +
|
||||||
@@ -206,8 +206,9 @@ export default class UserService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
const failures = this.loginThrottle.fail(ip, timestamp);
|
||||||
await this.updateAuthInfo(content, {
|
await this.updateAuthInfo(content, {
|
||||||
retries: retries + 1,
|
retries: failures,
|
||||||
lastlogon: timestamp,
|
lastlogon: timestamp,
|
||||||
lastip: ip,
|
lastip: ip,
|
||||||
lastaddr: address,
|
lastaddr: address,
|
||||||
@@ -218,7 +219,7 @@ export default class UserService {
|
|||||||
t('你于') +
|
t('你于') +
|
||||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
t('在') +
|
t('在') +
|
||||||
address +
|
t(address) +
|
||||||
' ' +
|
' ' +
|
||||||
req.platform +
|
req.platform +
|
||||||
t('端') +
|
t('端') +
|
||||||
@@ -239,8 +240,8 @@ export default class UserService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.getLoginLog();
|
this.getLoginLog();
|
||||||
if (retries > 2) {
|
if (failures > 3) {
|
||||||
const waitTime = Math.round(Math.pow(3, retries + 1));
|
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||||
return {
|
return {
|
||||||
code: 410,
|
code: 410,
|
||||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||||
@@ -439,8 +440,8 @@ export default class UserService {
|
|||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const retries = authInfo.retries || 0;
|
const source = getClientIp(req);
|
||||||
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
|
if (this.loginThrottle.retryAfter(source, now) > 0) {
|
||||||
return { code: 410, message: t('失败次数过多,请稍后重试') };
|
return { code: 410, message: t('失败次数过多,请稍后重试') };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -472,9 +473,8 @@ export default class UserService {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
retries: retries + 1,
|
retries: this.loginThrottle.fail(source, now),
|
||||||
lastlogon: now,
|
lastlogon: now,
|
||||||
isTwoFactorChecking: retries + 1 < 5,
|
|
||||||
lastip: ip,
|
lastip: ip,
|
||||||
lastaddr: address,
|
lastaddr: address,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
@@ -690,5 +690,8 @@ export default class UserService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.updateAuthInfo(authInfo, payload);
|
await this.updateAuthInfo(authInfo, payload);
|
||||||
|
if (retries === 0 || password !== undefined || username !== undefined) {
|
||||||
|
this.loginThrottle.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Match command text literally: looking up a task must never execute its input.
|
||||||
|
export function findCronId(crontab: string, command: string): string {
|
||||||
|
if (!command) return '';
|
||||||
|
for (const line of crontab.split('\n')) {
|
||||||
|
const id = /\bID=(\d+)\s/.exec(line);
|
||||||
|
if (id && line.includes(command, id.index + id[0].length)) {
|
||||||
|
return id[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
+147
-7
@@ -1,12 +1,150 @@
|
|||||||
import cronParser from 'cron-parser-v4';
|
import CronExpressionParser from 'cron-parser';
|
||||||
|
import { validate } from 'node-cron';
|
||||||
import { ScheduleType } from '../interface/schedule';
|
import { ScheduleType } from '../interface/schedule';
|
||||||
|
|
||||||
// Keep validation aligned with node-schedule's locked cron-parser version.
|
const aliases: Record<string, string> = {
|
||||||
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute.
|
'@yearly': '0 0 0 1 1 *',
|
||||||
|
'@annually': '0 0 0 1 1 *',
|
||||||
|
'@monthly': '0 0 0 1 * *',
|
||||||
|
'@weekly': '0 0 0 * * 0',
|
||||||
|
'@daily': '0 0 0 * * *',
|
||||||
|
'@midnight': '0 0 0 * * *',
|
||||||
|
'@hourly': '0 0 * * * *',
|
||||||
|
'@minutely': '0 * * * * *',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CronSchedule {
|
||||||
|
patterns: string[];
|
||||||
|
dayCount: number;
|
||||||
|
nthDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function parseCronSchedule(schedule: unknown): CronSchedule {
|
||||||
|
if (typeof schedule !== 'string' || !schedule.trim()) {
|
||||||
|
throw new Error('Invalid cron schedule');
|
||||||
|
}
|
||||||
|
let source = schedule.trim();
|
||||||
|
const cacheKey = source;
|
||||||
|
const cached = scheduleCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (source.startsWith('@')) {
|
||||||
|
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
|
||||||
|
source = aliases[source];
|
||||||
|
}
|
||||||
|
let parts = source.split(/\s+/);
|
||||||
|
if (
|
||||||
|
parts.length > 6 ||
|
||||||
|
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
|
||||||
|
) {
|
||||||
|
throw new Error('Unsupported cron syntax');
|
||||||
|
}
|
||||||
|
parts = [
|
||||||
|
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
|
||||||
|
...parts,
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
parts.some(
|
||||||
|
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Question mark is only valid in day fields');
|
||||||
|
}
|
||||||
|
const expression = CronExpressionParser.parse(parts.join(' '));
|
||||||
|
if (!expression.hasNext())
|
||||||
|
throw new Error('Cron schedule has no next execution');
|
||||||
|
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
|
||||||
|
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
|
||||||
|
// step does not land on 7. node-cron accepts those ranges with different days.
|
||||||
|
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
|
||||||
|
normalized[5] = expression.fields.dayOfWeek.values.join(',');
|
||||||
|
}
|
||||||
|
// Most rules are already supported; only inspect individual fields on failure.
|
||||||
|
if (!validate(normalized.join(' '))) {
|
||||||
|
const fields = [
|
||||||
|
expression.fields.second,
|
||||||
|
expression.fields.minute,
|
||||||
|
expression.fields.hour,
|
||||||
|
expression.fields.dayOfMonth,
|
||||||
|
expression.fields.month,
|
||||||
|
expression.fields.dayOfWeek,
|
||||||
|
];
|
||||||
|
normalized = normalized.map((value, index) => {
|
||||||
|
// Preserve supported lists, ranges and names. Expand only fields node-cron
|
||||||
|
// cannot consume, such as numeric/name-start steps; never compress them.
|
||||||
|
const probe = ['0', '0', '0', '*', '*', '*'];
|
||||||
|
probe[index] = value;
|
||||||
|
if (validate(probe.join(' '))) return value;
|
||||||
|
const values = fields[index].values;
|
||||||
|
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
|
||||||
|
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
|
||||||
|
if (values.length === 8) return '*';
|
||||||
|
// # applies to a single weekday; normalize its numeric spelling only.
|
||||||
|
return values.length === 1
|
||||||
|
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
return values.join(',');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const months = expression.fields.month.values;
|
||||||
|
// cron-parser 4 clipped dates outside a single selected month's maximum
|
||||||
|
// before deciding whether the day field covered the whole month.
|
||||||
|
const dayCount = expression.fields.dayOfMonth.values.filter(
|
||||||
|
(day) =>
|
||||||
|
months.length !== 1 ||
|
||||||
|
typeof day !== 'number' ||
|
||||||
|
day <= daysInMonth[months[0] - 1],
|
||||||
|
).length;
|
||||||
|
const weekCount = expression.fields.dayOfWeek.values.length;
|
||||||
|
// Preserve the legacy count-based wildcard rules, including repeated Sunday
|
||||||
|
// and L tokens: an ignored day field must not remain an AND constraint.
|
||||||
|
const weekdayPattern = [
|
||||||
|
...normalized.slice(0, 3),
|
||||||
|
'*',
|
||||||
|
...normalized.slice(4),
|
||||||
|
].join(' ');
|
||||||
|
const patterns =
|
||||||
|
weekCount === 8
|
||||||
|
? [[...normalized.slice(0, 5), '*'].join(' ')]
|
||||||
|
: dayCount < 31
|
||||||
|
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
|
||||||
|
: [weekdayPattern];
|
||||||
|
const valid = patterns.map((pattern) => validate(pattern));
|
||||||
|
if (patterns.length === 2 && !valid[0] && valid[1]) {
|
||||||
|
// A date branch can be impossible for all chosen months while the weekday
|
||||||
|
// branch still runs. Drop it only if its syntax is valid with any month.
|
||||||
|
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
|
||||||
|
patterns.shift();
|
||||||
|
valid.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!valid.every(Boolean)) {
|
||||||
|
throw new Error('Unsupported cron schedule');
|
||||||
|
}
|
||||||
|
const parsed = {
|
||||||
|
patterns,
|
||||||
|
dayCount,
|
||||||
|
nthDay: expression.fields.dayOfWeek.nthDay,
|
||||||
|
};
|
||||||
|
Object.freeze(patterns);
|
||||||
|
Object.freeze(parsed);
|
||||||
|
if (scheduleCache.size >= 512)
|
||||||
|
scheduleCache.delete(scheduleCache.keys().next().value);
|
||||||
|
scheduleCache.set(cacheKey, parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
||||||
if (typeof schedule !== 'string' || !schedule.trim()) return false;
|
|
||||||
try {
|
try {
|
||||||
return cronParser.parseExpression(schedule).hasNext();
|
parseCronSchedule(schedule);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -22,6 +160,8 @@ export function getInvalidCronSchedules(cron: {
|
|||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)]
|
return [
|
||||||
.filter((schedule) => !isValidCronSchedule(schedule));
|
cron.schedule,
|
||||||
|
...(cron.extra_schedules || []).map((x) => x.schedule),
|
||||||
|
].filter((schedule) => !isValidCronSchedule(schedule));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { t } from './i18n';
|
||||||
|
import { createTask, ScheduledTask } from 'node-cron';
|
||||||
|
import { parseCronSchedule } from './cronSchedule';
|
||||||
|
|
||||||
|
interface SchedulerLogger {
|
||||||
|
warn(message: string, ...args: unknown[]): unknown;
|
||||||
|
error(message: string, ...args: unknown[]): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronJob {
|
||||||
|
start(): void;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// node-cron owns timers and calendar calculation; Qinglong owns execution
|
||||||
|
// concurrency and process lifetime. Missed in-process slots join that same queue.
|
||||||
|
export function createCronJob(
|
||||||
|
schedule: string,
|
||||||
|
callback: (date: Date) => unknown | Promise<unknown>,
|
||||||
|
options: { name: string; logger: SchedulerLogger; start?: boolean },
|
||||||
|
): CronJob {
|
||||||
|
const parsed = parseCronSchedule(schedule);
|
||||||
|
const tasks: ScheduledTask[] = [];
|
||||||
|
let cancelled = false;
|
||||||
|
let started = false;
|
||||||
|
const accepts = (index: number, date: Date) => {
|
||||||
|
if (tasks.length === 1) return true;
|
||||||
|
// Preserve cron-parser 4's day/weekday OR semantics, including its
|
||||||
|
// month-dependent full-day range and the global nth-week constraint.
|
||||||
|
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
|
||||||
|
return false;
|
||||||
|
if (index === 1) return !tasks[0].match(date);
|
||||||
|
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
|
||||||
|
};
|
||||||
|
const dispatch = async (index: number, date: Date, missed: boolean) => {
|
||||||
|
if (cancelled || !started || !accepts(index, date)) return;
|
||||||
|
if (missed) {
|
||||||
|
options.logger.warn(
|
||||||
|
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
|
||||||
|
options.name,
|
||||||
|
date.toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await callback(date);
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.error(
|
||||||
|
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
|
||||||
|
options.name,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const [index, pattern] of parsed.patterns.entries()) {
|
||||||
|
const task = createTask(
|
||||||
|
pattern,
|
||||||
|
(context) => dispatch(index, context.date, false),
|
||||||
|
{
|
||||||
|
noOverlap: false,
|
||||||
|
missedExecutionTolerance: 5000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tasks.push(task);
|
||||||
|
task.on('execution:missed', (context) =>
|
||||||
|
dispatch(index, context.date, true),
|
||||||
|
);
|
||||||
|
// Fail before an existing schedule is cancelled, including impossible dates.
|
||||||
|
task.getNextRuns(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
tasks.forEach((task) => task.destroy());
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const job: CronJob = {
|
||||||
|
start() {
|
||||||
|
if (started || cancelled) return;
|
||||||
|
started = true;
|
||||||
|
tasks.forEach((task) => task.start());
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
tasks.forEach((task) => task.destroy());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (options.start !== false) job.start();
|
||||||
|
return job;
|
||||||
|
}
|
||||||
+224
-91
@@ -1,111 +1,234 @@
|
|||||||
import { shareStore } from './store';
|
|
||||||
|
|
||||||
const messages: Record<string, Record<string, string>> = {
|
const messages: Record<string, Record<string, string>> = {
|
||||||
zh: {},
|
zh: {},
|
||||||
en: {
|
en: {
|
||||||
'暂无权限': 'Access denied',
|
'升级任务启动失败,请检查日志后重试':
|
||||||
'参数错误': 'Invalid parameter',
|
'Failed to start the update. Check the logs and try again.',
|
||||||
'参数不正确': 'Invalid parameter',
|
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
|
||||||
'文件无法访问': 'File not accessible',
|
'[schedule][Stopping running task] Task ID: %s, PID: %s',
|
||||||
'文件不存在': 'File not found',
|
|
||||||
'路径不存在': 'Path does not exist',
|
'接口已下线,请使用 /configs/detail 接口':
|
||||||
'路径不正确': 'Invalid path',
|
'This endpoint has been retired. Use /configs/detail instead.',
|
||||||
|
'接口已下线,请使用 /logs/detail 接口':
|
||||||
|
'This endpoint has been retired. Use /logs/detail instead.',
|
||||||
|
'接口已下线,请使用 /scripts/detail 接口':
|
||||||
|
'This endpoint has been retired. Use /scripts/detail instead.',
|
||||||
|
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
|
||||||
|
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
|
||||||
|
请先初始化: 'Please initialize the panel first',
|
||||||
|
请先关闭两步验证: 'Please disable two-factor authentication first',
|
||||||
|
'失败次数过多,请稍后重试':
|
||||||
|
'Too many failed attempts. Please try again later.',
|
||||||
|
配置更新失败: 'Failed to update configuration',
|
||||||
|
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
|
||||||
|
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
|
||||||
|
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
|
||||||
|
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
|
||||||
|
系统配置不存在: 'System configuration does not exist',
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
|
||||||
|
'Version history only supports UTF-8 text scripts up to 1 MiB',
|
||||||
|
脚本不存在: 'Script does not exist',
|
||||||
|
请选择脚本文件: 'Please select a script file',
|
||||||
|
'历史版本读取失败,请检查历史文件':
|
||||||
|
'Failed to read version history. Check the history file.',
|
||||||
|
历史版本不存在或已清理:
|
||||||
|
'This history version does not exist or has been removed',
|
||||||
|
'文件已变化,请刷新历史版本后重试':
|
||||||
|
'The file has changed. Refresh version history and try again.',
|
||||||
|
'历史版本容量不足,无法安全保存':
|
||||||
|
'Version history storage is full. The file cannot be saved safely.',
|
||||||
|
'跳过订阅 %s 的无效定时配置: %s':
|
||||||
|
'Skipping invalid schedule for subscription %s: %s',
|
||||||
|
'[schedule][准备运行任务] 命令: %s':
|
||||||
|
'[schedule][Preparing task] Command: %s',
|
||||||
|
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
|
||||||
|
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
|
||||||
|
'[schedule][取消定时任务] 任务ID: %s':
|
||||||
|
'[schedule][Cancelling scheduled task] Task ID: %s',
|
||||||
|
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
|
||||||
|
'[panel][停止任务失败] PID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to stop task] PID: %s, error: %s',
|
||||||
|
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
|
||||||
|
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[panel][Task failed] Task ID: %s, error: %s',
|
||||||
|
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||||
|
'[panel][Task finished] Task ID: %s, exit code: %j',
|
||||||
|
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to create task] Task ID: %s, error: %s',
|
||||||
|
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
|
||||||
|
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
|
||||||
|
'性能指标报告:': 'Performance metrics:',
|
||||||
|
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
|
||||||
|
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
|
||||||
|
'[panel][任务结束回调失败] %s':
|
||||||
|
'[panel][Task completion callback failed] %s',
|
||||||
|
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
|
||||||
|
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
|
||||||
|
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
|
||||||
|
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
|
||||||
|
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
|
||||||
|
'[panel][取消定时任务] 任务名: %s':
|
||||||
|
'[panel][Cancelling scheduled task] Name: %s',
|
||||||
|
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
|
||||||
|
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
|
||||||
|
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
|
||||||
|
'[panel][Task failed] Command: %s, error: %j',
|
||||||
|
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
|
||||||
|
'[panel][Cancelling interval task] Task ID: %s, name: %s',
|
||||||
|
生成私钥文件失败: 'Failed to generate private key file',
|
||||||
|
删除私钥文件失败: 'Failed to delete private key file',
|
||||||
|
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
|
||||||
|
'跳过订阅 %s 的无效 SSH 配置: %s':
|
||||||
|
'Skipping invalid SSH configuration for subscription %s: %s',
|
||||||
|
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
|
||||||
|
'[schedule][Running late task] Task: %s, scheduled time: %s',
|
||||||
|
'[schedule][定时回调失败] 任务: %s, 错误: %s':
|
||||||
|
'[schedule][Scheduled callback failed] Task: %s, error: %s',
|
||||||
|
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
|
||||||
|
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Task queued] Running: %s, waiting: %s',
|
||||||
|
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Processing task] Running: %s, waiting: %s',
|
||||||
|
'[schedule][任务处理成功] 参数 %s':
|
||||||
|
'[schedule][Task succeeded] Arguments: %s',
|
||||||
|
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
|
||||||
|
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Task finished] Running: %s, waiting: %s',
|
||||||
|
'[schedule][任务重复运行] 通知失败 %s':
|
||||||
|
'[schedule][Duplicate task] Notification failed: %s',
|
||||||
|
'[schedule][任务重复运行] 参数 %s':
|
||||||
|
'[schedule][Duplicate task] Arguments: %s',
|
||||||
|
'[schedule][停止已运行任务] 任务ID: %s':
|
||||||
|
'[schedule][Stopping running task] Task ID: %s',
|
||||||
|
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to check running task] Task ID: %s, error: %s',
|
||||||
|
'[schedule][开始执行任务] 参数 %s':
|
||||||
|
'[schedule][Starting task] Arguments: %s',
|
||||||
|
'[schedule][任务标准错误] 命令: %s, 信息: %s':
|
||||||
|
'[schedule][Task stderr] Command: %s, message: %s',
|
||||||
|
'[schedule][执行任务失败] 命令: %s, 错误: %s':
|
||||||
|
'[schedule][Task failed] Command: %s, error: %s',
|
||||||
|
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||||
|
'[schedule][Task finished] Task ID: %s, exit code: %j',
|
||||||
|
'[schedule][创建任务失败] 命令: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to create task] Command: %s, error: %s',
|
||||||
|
|
||||||
|
'检查更新失败,请检查网络后重试':
|
||||||
|
'Failed to check for updates. Check your network and try again.',
|
||||||
|
'资源已存在,请检查重复的名称或值':
|
||||||
|
'Resource already exists; check for duplicate names or values',
|
||||||
|
暂无权限: 'Access denied',
|
||||||
|
参数错误: 'Invalid parameter',
|
||||||
|
参数不正确: 'Invalid parameter',
|
||||||
|
文件无法访问: 'File not accessible',
|
||||||
|
文件不存在: 'File not found',
|
||||||
|
路径不存在: 'Path does not exist',
|
||||||
|
路径不正确: 'Invalid path',
|
||||||
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
||||||
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
|
'通知发送失败,请检查系统设置/通知配置':
|
||||||
'通知发送成功': 'Notification sent successfully',
|
'Notification failed, check system settings',
|
||||||
'设置时区失败': 'Failed to set timezone',
|
通知发送成功: 'Notification sent successfully',
|
||||||
'任务未找到': 'Task not found',
|
设置时区失败: 'Failed to set timezone',
|
||||||
'密码不能设置为admin': 'Password cannot be admin',
|
任务未找到: 'Task not found',
|
||||||
'更新成功': 'Update successful',
|
密码不能设置为admin: 'Password cannot be admin',
|
||||||
'未知错误': 'Unknown error',
|
更新成功: 'Update successful',
|
||||||
'验证失败': 'Verification failed',
|
未知错误: 'Unknown error',
|
||||||
'实例不存在或已停止': 'Instance does not exist or stopped',
|
验证失败: 'Verification failed',
|
||||||
'实例已停止': 'Instance stopped',
|
实例不存在或已停止: 'Instance does not exist or stopped',
|
||||||
'确认停止实例': 'Confirm to stop instance',
|
实例已停止: 'Instance stopped',
|
||||||
'确认停止运行实例': 'Confirm to stop running instance',
|
确认停止实例: 'Confirm to stop instance',
|
||||||
'确认停止': 'Confirm to stop',
|
确认停止运行实例: 'Confirm to stop running instance',
|
||||||
'确认停止定时任务': 'Confirm to stop scheduled task',
|
确认停止: 'Confirm to stop',
|
||||||
'确认删除': 'Confirm to delete',
|
确认停止定时任务: 'Confirm to stop scheduled task',
|
||||||
'确认删除定时任务': 'Confirm to delete scheduled task',
|
确认删除: 'Confirm to delete',
|
||||||
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
|
确认删除定时任务: 'Confirm to delete scheduled task',
|
||||||
'确认运行': 'Confirm to run',
|
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
|
||||||
'确认运行定时任务': 'Confirm to run scheduled task',
|
确认运行: 'Confirm to run',
|
||||||
'确认保存': 'Confirm to save',
|
确认运行定时任务: 'Confirm to run scheduled task',
|
||||||
'确认保存文件': 'Confirm to save file',
|
确认保存: 'Confirm to save',
|
||||||
'确认重启': 'Confirm restart',
|
确认保存文件: 'Confirm to save file',
|
||||||
'确认启用': 'Confirm to enable',
|
确认重启: 'Confirm restart',
|
||||||
'确认禁用': 'Confirm to disable',
|
确认启用: 'Confirm to enable',
|
||||||
'确认': 'Confirm',
|
确认禁用: 'Confirm to disable',
|
||||||
'删除成功': 'Deleted successfully',
|
确认: 'Confirm',
|
||||||
'操作成功': 'Operation successful',
|
删除成功: 'Deleted successfully',
|
||||||
'参数不完整': 'Incomplete parameters',
|
操作成功: 'Operation successful',
|
||||||
'默认路径不支持删除': 'Default path cannot be deleted',
|
参数不完整: 'Incomplete parameters',
|
||||||
'必须在日志目录下': 'Must be within log directory',
|
默认路径不支持删除: 'Default path cannot be deleted',
|
||||||
|
必须在日志目录下: 'Must be within log directory',
|
||||||
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
||||||
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
||||||
'系统将在': 'System will',
|
系统将在: 'System will',
|
||||||
'秒后自动刷新': 'refresh in seconds',
|
秒后自动刷新: 'refresh in seconds',
|
||||||
'生成数据中...': 'Generating data...',
|
'生成数据中...': 'Generating data...',
|
||||||
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
|
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
|
||||||
'不支持当前依赖类型': 'Unsupported dependency type',
|
'Each entry must have name and value, see export format',
|
||||||
'依赖已存在': 'Dependency already exists',
|
不支持当前依赖类型: 'Unsupported dependency type',
|
||||||
'依赖不存在': 'Dependency does not exist',
|
依赖已存在: 'Dependency already exists',
|
||||||
'该脚本正在运行中': 'Script is running',
|
依赖不存在: 'Dependency does not exist',
|
||||||
'该脚本未在运行中': 'Script is not running',
|
该脚本正在运行中: 'Script is running',
|
||||||
'文件内容为空': 'File content is empty',
|
该脚本未在运行中: 'Script is not running',
|
||||||
'文件名不能为空': 'File name cannot be empty',
|
文件内容为空: 'File content is empty',
|
||||||
'标签不能为空': 'Label cannot be empty',
|
文件名不能为空: 'File name cannot be empty',
|
||||||
'名称不能为空': 'Name cannot be empty',
|
标签不能为空: 'Label cannot be empty',
|
||||||
'名称不能为保留关键字': 'Name cannot be reserved keyword',
|
名称不能为空: 'Name cannot be empty',
|
||||||
'名称已存在': 'Name already exists',
|
名称不能为保留关键字: 'Name cannot be reserved keyword',
|
||||||
'密码错误': 'Incorrect password',
|
名称已存在: 'Name already exists',
|
||||||
'用户不存在': 'User does not exist',
|
密码错误: 'Incorrect password',
|
||||||
'请输入用户名和密码': 'Please enter username and password',
|
用户不存在: 'User does not exist',
|
||||||
'无权访问': 'Access denied',
|
请输入用户名和密码: 'Please enter username and password',
|
||||||
'登录成功': 'Login successful',
|
无权访问: 'Access denied',
|
||||||
'退出成功': 'Logout successful',
|
登录成功: 'Login successful',
|
||||||
|
退出成功: 'Logout successful',
|
||||||
'Token 已失效': 'Token expired',
|
'Token 已失效': 'Token expired',
|
||||||
'Token 无效': 'Invalid token',
|
'Token 无效': 'Invalid token',
|
||||||
'两步骤验证已开启': '2FA enabled',
|
两步骤验证已开启: '2FA enabled',
|
||||||
'两步骤验证已关闭': '2FA disabled',
|
两步骤验证已关闭: '2FA disabled',
|
||||||
'验证码错误': 'Invalid verification code',
|
验证码错误: 'Invalid verification code',
|
||||||
'验证码已过期': 'Verification code expired',
|
验证码已过期: 'Verification code expired',
|
||||||
'请先开启两步骤验证': 'Please enable 2FA first',
|
请先开启两步骤验证: 'Please enable 2FA first',
|
||||||
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
|
两步骤验证密钥不能为空: '2FA secret cannot be empty',
|
||||||
'用户已存在': 'User already exists',
|
用户已存在: 'User already exists',
|
||||||
'用户名不能为admin': 'Username cannot be admin',
|
用户名不能为admin: 'Username cannot be admin',
|
||||||
'不能删除自己': 'Cannot delete yourself',
|
不能删除自己: 'Cannot delete yourself',
|
||||||
'不能禁用自己': 'Cannot disable yourself',
|
不能禁用自己: 'Cannot disable yourself',
|
||||||
'文件路径无效': 'Invalid file path',
|
文件路径无效: 'Invalid file path',
|
||||||
'保存成功': 'Saved successfully',
|
保存成功: 'Saved successfully',
|
||||||
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
||||||
'订阅执行完成': 'Subscription completed',
|
订阅执行完成: 'Subscription completed',
|
||||||
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
|
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
|
||||||
|
'wxPusher requires at least one of TopicIds or Uids',
|
||||||
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
||||||
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
||||||
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
||||||
'绝对路径必须在日志目录内或使用 /dev/null':
|
'绝对路径必须在日志目录内或使用 /dev/null':
|
||||||
'Absolute path must be within log directory or use /dev/null',
|
'Absolute path must be within log directory or use /dev/null',
|
||||||
'请先登录': 'Please login first',
|
请先登录: 'Please login first',
|
||||||
'运行中...': 'Running...',
|
'运行中...': 'Running...',
|
||||||
'日志不存在...': 'Log does not exist...',
|
'日志不存在...': 'Log does not exist...',
|
||||||
'未分类': 'Uncategorized',
|
未分类: 'Uncategorized',
|
||||||
'任务重复运行': 'Duplicate task execution',
|
任务重复运行: 'Duplicate task execution',
|
||||||
'日志设置为忽略': 'Log set to ignore',
|
日志设置为忽略: 'Log set to ignore',
|
||||||
'定时规则不能为空': 'Schedule rule cannot be empty',
|
定时规则不能为空: 'Schedule rule cannot be empty',
|
||||||
'无效的定时规则': 'Invalid schedule rule',
|
无效的定时规则: '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',
|
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
|
||||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
|
||||||
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
'错误的用户名密码,请重试':
|
||||||
|
'Incorrect username or password, please try again',
|
||||||
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
||||||
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
||||||
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
||||||
'青龙快讯': 'QingLong',
|
青龙快讯: 'QingLong',
|
||||||
'登录通知': 'Login Notification',
|
登录通知: 'Login Notification',
|
||||||
'你于': 'You at ',
|
你于: 'You at ',
|
||||||
'在': ' in ',
|
在: ' in ',
|
||||||
端: '',
|
端: ' client',
|
||||||
|
内网IP: 'Private IP',
|
||||||
登录失败: 'login failed',
|
登录失败: 'login failed',
|
||||||
',ip地址': ', IP: ',
|
',ip地址': ', IP: ',
|
||||||
'任务#%s': 'Task#%s',
|
'任务#%s': 'Task#%s',
|
||||||
@@ -130,12 +253,13 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
青龙: 'QingLong',
|
青龙: 'QingLong',
|
||||||
'【蛟龙】测试通知 https://t.me/jiao_long':
|
'【蛟龙】测试通知 https://t.me/jiao_long':
|
||||||
'[JiaoLong] Test notification https://t.me/jiao_long',
|
'[JiaoLong] Test notification https://t.me/jiao_long',
|
||||||
'生成token': 'Generate token',
|
生成token: 'Generate token',
|
||||||
'删除日志': 'Delete logs',
|
删除日志: 'Delete logs',
|
||||||
'## 开始执行... %s\n': '## Start executing... %s\n',
|
'## 开始执行... %s\n': '## Start executing... %s\n',
|
||||||
'执行before命令...': 'Execute before command...',
|
'执行before命令...': 'Execute before command...',
|
||||||
'执行after命令...': 'Execute after 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',
|
'Scheduler registration failed, task creation rolled back',
|
||||||
'调度器注册失败,任务更新已回滚':
|
'调度器注册失败,任务更新已回滚':
|
||||||
@@ -151,9 +275,18 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
|
|
||||||
let currentLang: string = 'zh';
|
let currentLang: string = 'zh';
|
||||||
|
|
||||||
export function setLang(lang: string) {
|
export function setLang(lang: string, persist = true) {
|
||||||
currentLang = lang || 'zh';
|
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) */
|
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
|
|||||||
export interface ICronFn<T> {
|
export interface ICronFn<T> {
|
||||||
(): Promise<T>;
|
(): Promise<T>;
|
||||||
cron?: TCron;
|
cron?: TCron;
|
||||||
|
isCurrent?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISchedule {
|
export interface ISchedule {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import cluster from 'cluster';
|
||||||
|
import { setLang, systemLang } from './i18n';
|
||||||
|
|
||||||
|
function languageMessage(
|
||||||
|
message: unknown,
|
||||||
|
): message is { type: string; lang: string } {
|
||||||
|
const value = message as { type?: string; lang?: string } | null;
|
||||||
|
return (
|
||||||
|
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each worker owns a translation cache. Relay saved settings to live workers;
|
||||||
|
// a replacement worker restores the persisted language before serving requests.
|
||||||
|
export async function initializeLanguageSync() {
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
cluster.on('message', (_worker, message) => {
|
||||||
|
if (!languageMessage(message)) return;
|
||||||
|
setLang(message.lang, false);
|
||||||
|
for (const worker of Object.values(cluster.workers || {})) {
|
||||||
|
if (worker?.isConnected()) worker.send(message, () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
process.on('message', (message) => {
|
||||||
|
if (languageMessage(message)) setLang(message.lang, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { shareStore } = await import('./store');
|
||||||
|
setLang((await shareStore.getLang()) || systemLang(), false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { shellQuote } from './shellQuote';
|
||||||
|
|
||||||
|
const repositoryNames =
|
||||||
|
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
|
||||||
|
|
||||||
|
export function normalizeMirror(mirror: string): URL {
|
||||||
|
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
|
||||||
|
const url = new URL(mirror);
|
||||||
|
if (
|
||||||
|
!['http:', 'https:'].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMirrorUrl(current: string, mirror: string): string {
|
||||||
|
const target = normalizeMirror(mirror);
|
||||||
|
const previous = new URL(current);
|
||||||
|
const oldSegments = previous.pathname.split('/').filter(Boolean);
|
||||||
|
const newSegments = target.pathname.split('/').filter(Boolean);
|
||||||
|
const repositoryIndex = oldSegments.findIndex((part) =>
|
||||||
|
repositoryNames.test(part),
|
||||||
|
);
|
||||||
|
const targetIndex = newSegments.findIndex((part) =>
|
||||||
|
repositoryNames.test(part),
|
||||||
|
);
|
||||||
|
const prefix =
|
||||||
|
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
|
||||||
|
if (repositoryIndex >= 0) {
|
||||||
|
const repository = oldSegments[repositoryIndex];
|
||||||
|
let suffix = oldSegments.slice(repositoryIndex + 1);
|
||||||
|
// Repair paths already corrupted by repeated authority-only replacement.
|
||||||
|
while (suffix[0] === repository) suffix.shift();
|
||||||
|
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
|
||||||
|
}
|
||||||
|
// Unknown repository paths: a full URL replaces the old root, a hostname
|
||||||
|
// preserves it. Never append the newly entered root to the existing root.
|
||||||
|
return `${target.origin}/${(newSegments.length
|
||||||
|
? newSegments
|
||||||
|
: oldSegments
|
||||||
|
).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rewriteMirrorSources(content: string, mirror: string): string {
|
||||||
|
normalizeMirror(mirror);
|
||||||
|
return content
|
||||||
|
.split(/(\r?\n[ \t]*\r?\n)/)
|
||||||
|
.map((stanza) => {
|
||||||
|
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
|
||||||
|
return stanza
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
|
||||||
|
return line;
|
||||||
|
const legacy = /^\s*deb(?:-src)?\s/.test(line);
|
||||||
|
const key = legacy
|
||||||
|
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
|
||||||
|
: signedBy;
|
||||||
|
// In .list files a # starts an inline comment; its URLs are metadata.
|
||||||
|
const comment = legacy ? line.indexOf('#') : -1;
|
||||||
|
const source = comment < 0 ? line : line.slice(0, comment);
|
||||||
|
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
|
||||||
|
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
|
||||||
|
);
|
||||||
|
return rewritten + (comment < 0 ? '' : line.slice(comment));
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemRepository(url: string, signedBy?: string): boolean {
|
||||||
|
const keys =
|
||||||
|
signedBy
|
||||||
|
?.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean) || [];
|
||||||
|
const archiveKeyring =
|
||||||
|
keys.length > 0 &&
|
||||||
|
keys.every((key) =>
|
||||||
|
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
|
||||||
|
);
|
||||||
|
// A separate signing key identifies a third-party archive, even if its
|
||||||
|
// repository happens to be called /debian or /ubuntu.
|
||||||
|
if (keys.length > 0 && !archiveKeyring) return false;
|
||||||
|
const segments = new URL(url).pathname.split('/').filter(Boolean);
|
||||||
|
const repositoryIndex = segments.findIndex((part) =>
|
||||||
|
repositoryNames.test(part),
|
||||||
|
);
|
||||||
|
if (repositoryIndex < 0) return false;
|
||||||
|
// Without archive metadata, accept only conventional archive roots. A
|
||||||
|
// vendor path such as Docker's /linux/debian is not the Debian archive.
|
||||||
|
return (
|
||||||
|
archiveKeyring ||
|
||||||
|
repositoryIndex === 0 ||
|
||||||
|
segments
|
||||||
|
.slice(0, repositoryIndex)
|
||||||
|
.every((part) => /^mirrors?$/.test(part)) ||
|
||||||
|
segments[repositoryIndex] === 'alpine'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mirrorWriteCommand(
|
||||||
|
file: string,
|
||||||
|
content: string,
|
||||||
|
prefix: string,
|
||||||
|
update: string,
|
||||||
|
): string {
|
||||||
|
return `${prefix}mkdir -p ${shellQuote(
|
||||||
|
file.slice(0, file.lastIndexOf('/')),
|
||||||
|
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
|
||||||
|
file,
|
||||||
|
)} >/dev/null && ${update}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { resolveFileAccess } from './fileAccess';
|
||||||
|
|
||||||
|
/** Check stored paths too: old database rows are not trusted input. */
|
||||||
|
export function resolveLogPath(root: string, value: string): string {
|
||||||
|
const target = resolveFileAccess(root, [value]);
|
||||||
|
if (!target)
|
||||||
|
throw Object.assign(new Error('Log path is outside the log directory'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const RETRY_TTL = 15 * 60 * 1000;
|
||||||
|
const MAX_SOURCES = 10000;
|
||||||
|
const MAX_RETRY_SECONDS = 5 * 60;
|
||||||
|
|
||||||
|
/** Source-specific, bounded backoff; persisted account history is not a lock. */
|
||||||
|
export class LoginThrottle {
|
||||||
|
private sources = new Map<
|
||||||
|
string,
|
||||||
|
{ failures: number; lastFailure: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private get(source: string, now: number) {
|
||||||
|
const state = this.sources.get(source);
|
||||||
|
if (state && now - state.lastFailure >= RETRY_TTL) {
|
||||||
|
this.sources.delete(source);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfter(source: string, now: number): number {
|
||||||
|
const state = this.get(source, now);
|
||||||
|
if (!state || state.failures < 3) return 0;
|
||||||
|
const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS);
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(source: string, now: number): number {
|
||||||
|
const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6);
|
||||||
|
this.sources.delete(source);
|
||||||
|
if (this.sources.size >= MAX_SOURCES) {
|
||||||
|
this.sources.delete(this.sources.keys().next().value!);
|
||||||
|
}
|
||||||
|
this.sources.set(source, { failures, lastFailure: now });
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(source: string) {
|
||||||
|
this.sources.delete(source);
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.sources.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-19
@@ -50,11 +50,9 @@ class TaskLimit {
|
|||||||
Buffer.from(tlsConfig.clientCert),
|
Buffer.from(tlsConfig.clientCert),
|
||||||
)
|
)
|
||||||
: credentials.createInsecure();
|
: credentials.createInsecure();
|
||||||
this._client = new ApiClient(
|
this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, {
|
||||||
`localhost:${config.grpcPort}`,
|
'grpc.enable_http_proxy': 0,
|
||||||
creds,
|
});
|
||||||
{ 'grpc.enable_http_proxy': 0 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return this._client;
|
return this._client;
|
||||||
}
|
}
|
||||||
@@ -81,29 +79,36 @@ class TaskLimit {
|
|||||||
private handleEvents() {
|
private handleEvents() {
|
||||||
this.cronLimit.on('add', () => {
|
this.cronLimit.on('add', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
|
this.cronLimitActiveCount,
|
||||||
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('active', () => {
|
this.cronLimit.on('active', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][开始处理任务] 运行中任务数: ${
|
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
this.cronLimitActiveCount + 1
|
this.cronLimitActiveCount + 1,
|
||||||
}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('completed', (param) => {
|
this.cronLimit.on('completed', (param) => {
|
||||||
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
|
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
|
||||||
});
|
});
|
||||||
this.cronLimit.on('error', (error) => {
|
this.cronLimit.on('error', (error) => {
|
||||||
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
|
Logger.error(
|
||||||
|
t('[schedule][任务处理错误] 参数 %s'),
|
||||||
|
JSON.stringify(error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('next', () => {
|
this.cronLimit.on('next', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
|
this.cronLimitActiveCount,
|
||||||
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('idle', () => {
|
this.cronLimit.on('idle', () => {
|
||||||
Logger.info(`[schedule][任务队列] 空闲中...`);
|
Logger.info(t(`[schedule][任务队列] 空闲中...`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +118,15 @@ class TaskLimit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeQueuedCron(id: string) {
|
public removeQueuedCron(id: string, completed?: ICronFn<any>) {
|
||||||
if (this.queuedCrons.has(id)) {
|
if (this.queuedCrons.has(id)) {
|
||||||
const runs = this.queuedCrons.get(id);
|
const runs = this.queuedCrons.get(id);
|
||||||
if (runs && runs.length > 0) {
|
if (runs && runs.length > 0) {
|
||||||
runs.pop();
|
const remaining = completed
|
||||||
this.queuedCrons.set(id, runs);
|
? runs.filter((run) => run !== completed)
|
||||||
|
: runs.slice(0, -1);
|
||||||
|
if (remaining.length) this.queuedCrons.set(id, remaining);
|
||||||
|
else this.queuedCrons.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +151,15 @@ class TaskLimit {
|
|||||||
cron: TCron,
|
cron: TCron,
|
||||||
fn: ICronFn<T>,
|
fn: ICronFn<T>,
|
||||||
options?: Partial<QueueAddOptions>,
|
options?: Partial<QueueAddOptions>,
|
||||||
|
isCurrent: () => boolean = () => true,
|
||||||
): Promise<T | void> {
|
): Promise<T | void> {
|
||||||
|
if (!isCurrent()) return;
|
||||||
fn.cron = cron;
|
fn.cron = cron;
|
||||||
let runs = this.queuedCrons.get(cron.id);
|
fn.isCurrent = isCurrent;
|
||||||
|
// Invalidated snapshots must not consume the new revision's repeat budget.
|
||||||
|
const runs = this.queuedCrons
|
||||||
|
.get(cron.id)
|
||||||
|
?.filter((run) => run.isCurrent?.() !== false);
|
||||||
const result = runs?.length ? [...runs, fn] : [fn];
|
const result = runs?.length ? [...runs, fn] : [fn];
|
||||||
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
|
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
|
||||||
if (result?.length > 5) {
|
if (result?.length > 5) {
|
||||||
@@ -165,13 +179,14 @@ class TaskLimit {
|
|||||||
(err, res) => {
|
(err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
Logger.error(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.queuedCrons.set(cron.id, result);
|
this.queuedCrons.set(cron.id, result);
|
||||||
|
|||||||
+110
-36
@@ -1,73 +1,146 @@
|
|||||||
|
import { t } from './i18n';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import taskLimit from './pLimit';
|
import taskLimit from './pLimit';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { ICron } from '../protos/cron';
|
import { ICron } from '../protos/cron';
|
||||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||||
import { killTask } from '../config/util';
|
|
||||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { observeChildProcess, asError } from './childProcess';
|
import { observeChildProcess, asError } from './childProcess';
|
||||||
|
|
||||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
export function runCron(
|
||||||
return taskLimit.runWithCronLimit(cron, async () => {
|
cmd: string,
|
||||||
|
cron: ICron,
|
||||||
|
isCurrent: () => boolean = () => true,
|
||||||
|
): Promise<number | void> {
|
||||||
|
const execute = async () => {
|
||||||
try {
|
try {
|
||||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
if (!isCurrent()) return;
|
||||||
|
// A single-instance trigger must not interrupt work already in progress.
|
||||||
try {
|
try {
|
||||||
const existingCron = await CrontabModel.findOne({
|
const existingCron = await CrontabModel.findOne({
|
||||||
where: { id: Number(cron.id) },
|
where: { id: Number(cron.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A mutation may commit while this queued run is reading the database.
|
||||||
|
if (!isCurrent() || !existingCron || existingCron.isDisabled) return;
|
||||||
|
|
||||||
// Default to single instance mode (0) for backward compatibility
|
// Default to single instance mode (0) for backward compatibility
|
||||||
const allowSingleInstances =
|
const allowSingleInstances =
|
||||||
existingCron?.allow_multiple_instances === 0;
|
existingCron?.allow_multiple_instances === 0;
|
||||||
|
|
||||||
if (
|
if (allowSingleInstances) {
|
||||||
allowSingleInstances &&
|
// A queued manual run is an active reservation, even if its stored PID
|
||||||
existingCron &&
|
// belongs to a previous execution.
|
||||||
existingCron.pid &&
|
if (
|
||||||
(existingCron.status === CrontabStatus.running ||
|
existingCron.status === CrontabStatus.queued &&
|
||||||
existingCron.status === CrontabStatus.queued)
|
existingCron.pid
|
||||||
) {
|
) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
||||||
);
|
cron.id,
|
||||||
await killTask(existingCron.pid, true);
|
existingCron.pid,
|
||||||
// Mark old running instances as stopped
|
);
|
||||||
const stoppedAt = dayjs().unix();
|
return;
|
||||||
await RunningInstanceModel.update(
|
}
|
||||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
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: {
|
where: {
|
||||||
cron_id: Number(cron.id),
|
cron_id: Number(cron.id),
|
||||||
|
pid: existingCron.pid,
|
||||||
status: InstanceStatus.running,
|
status: InstanceStatus.running,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
if (!isCurrent()) return;
|
||||||
// Update the status to idle after killing
|
const [recovered] = await CrontabModel.update(
|
||||||
await CrontabModel.update(
|
{
|
||||||
{ status: CrontabStatus.idle, pid: undefined },
|
status: CrontabStatus.idle,
|
||||||
{ where: { id: Number(cron.id) } },
|
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) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
|
cron.id,
|
||||||
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidation can happen during the asynchronous database check.
|
||||||
|
if (!isCurrent()) return;
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
t('[schedule][开始执行任务] 参数 %s'),
|
||||||
|
JSON.stringify({
|
||||||
...cron,
|
...cron,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
})}`,
|
}),
|
||||||
);
|
);
|
||||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||||
|
|
||||||
const { completed } = observeChildProcess(cp, {
|
const { completed } = observeChildProcess(cp, {
|
||||||
onStderr: async (message) => {
|
onStderr: async (message) => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][任务标准错误] 命令: %s, 信息: %s',
|
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
@@ -76,25 +149,26 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
|||||||
const result = await completed;
|
const result = await completed;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
'[schedule][执行任务失败] 命令: %s, 错误: %s',
|
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
result.error.message,
|
result.error.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
cron.id,
|
cron.id,
|
||||||
result.code,
|
result.code,
|
||||||
);
|
);
|
||||||
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
'[schedule][创建任务失败] 命令: %s, 错误: %s',
|
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
taskLimit.removeQueuedCron(cron.id);
|
taskLimit.removeQueuedCron(cron.id, execute);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { brotliCompress, brotliDecompress, constants } from 'zlib';
|
||||||
|
import { lock } from 'proper-lockfile';
|
||||||
|
import { resolveFileAccess } from './fileAccess';
|
||||||
|
import { getUniqueLockPath } from './utils';
|
||||||
|
|
||||||
|
const compress = promisify(brotliCompress);
|
||||||
|
const decompress = promisify(brotliDecompress);
|
||||||
|
const hash = (content: string) =>
|
||||||
|
createHash('sha256').update(content).digest('hex');
|
||||||
|
|
||||||
|
export const SCRIPT_HISTORY_LIMITS = {
|
||||||
|
versions: 20,
|
||||||
|
fileBytes: 1024 * 1024,
|
||||||
|
unpackedBytes: 8 * 1024 * 1024,
|
||||||
|
archiveBytes: 2 * 1024 * 1024,
|
||||||
|
totalBytes: 32 * 1024 * 1024,
|
||||||
|
files: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Source = 'baseline' | 'save' | 'restore' | 'external';
|
||||||
|
interface Version {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
source: Source;
|
||||||
|
hash: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
interface Archive {
|
||||||
|
format: 1;
|
||||||
|
path: string;
|
||||||
|
versions: Version[];
|
||||||
|
head?: { hash: string; createdAt: string; source: Source };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScriptHistoryError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HistoryUnavailableError extends ScriptHistoryError {
|
||||||
|
constructor(public currentHash: string) {
|
||||||
|
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface SaveOptions {
|
||||||
|
skipHistory?: boolean;
|
||||||
|
expectedHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
|
||||||
|
* Full snapshots share a Brotli window, so similar revisions compress together
|
||||||
|
* without a patch chain. All mutations use the existing script lock and a
|
||||||
|
* cross-process archive lock. History is durable before overwriting the script. */
|
||||||
|
export class ScriptHistory {
|
||||||
|
constructor(
|
||||||
|
private scriptRoot: string,
|
||||||
|
private historyRoot: string,
|
||||||
|
private blacklist: string[] = [],
|
||||||
|
private limits = SCRIPT_HISTORY_LIMITS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async target(directory: string, filename: string) {
|
||||||
|
const candidate = resolveFileAccess(
|
||||||
|
this.scriptRoot,
|
||||||
|
[directory, filename],
|
||||||
|
this.blacklist,
|
||||||
|
);
|
||||||
|
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
|
||||||
|
try {
|
||||||
|
const file = await fs.realpath(candidate);
|
||||||
|
const root = await fs.realpath(this.scriptRoot);
|
||||||
|
const relative = path.relative(root, file);
|
||||||
|
const key = hash(relative);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
relative,
|
||||||
|
archive: path.join(this.historyRoot, key + '.br'),
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT')
|
||||||
|
throw new ScriptHistoryError(404, '脚本不存在');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readText(file: string) {
|
||||||
|
const handle = await fs.open(file, 'r');
|
||||||
|
try {
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
|
||||||
|
if (stat.size > this.limits.fileBytes)
|
||||||
|
throw new ScriptHistoryError(
|
||||||
|
413,
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||||
|
);
|
||||||
|
// Bounded read even if an external process grows the file after stat.
|
||||||
|
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
|
||||||
|
let size = 0;
|
||||||
|
while (size < bytes.length) {
|
||||||
|
const { bytesRead } = await handle.read(
|
||||||
|
bytes,
|
||||||
|
size,
|
||||||
|
bytes.length - size,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (!bytesRead) break;
|
||||||
|
size += bytesRead;
|
||||||
|
}
|
||||||
|
const buffer = bytes.subarray(0, size);
|
||||||
|
const content = buffer.toString('utf8');
|
||||||
|
if (
|
||||||
|
size > this.limits.fileBytes ||
|
||||||
|
content.includes('\0') ||
|
||||||
|
!Buffer.from(content).equals(buffer)
|
||||||
|
) {
|
||||||
|
throw new ScriptHistoryError(
|
||||||
|
413,
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readArchive(file: string, relative: string): Promise<Archive> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(file);
|
||||||
|
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
|
||||||
|
throw new Error('Invalid archive');
|
||||||
|
const bytes = await decompress(await fs.readFile(file), {
|
||||||
|
maxOutputLength: this.limits.unpackedBytes,
|
||||||
|
});
|
||||||
|
const archive: Archive = JSON.parse(bytes.toString('utf8'));
|
||||||
|
if (
|
||||||
|
archive.format !== 1 ||
|
||||||
|
archive.path !== relative ||
|
||||||
|
!Array.isArray(archive.versions) ||
|
||||||
|
archive.versions.length > this.limits.versions ||
|
||||||
|
archive.versions.some(
|
||||||
|
(v) =>
|
||||||
|
typeof v.content !== 'string' ||
|
||||||
|
typeof v.id !== 'string' ||
|
||||||
|
typeof v.createdAt !== 'string' ||
|
||||||
|
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
|
||||||
|
Buffer.byteLength(v.content) > this.limits.fileBytes ||
|
||||||
|
hash(v.content) !== v.hash,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid archive');
|
||||||
|
}
|
||||||
|
return archive;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT')
|
||||||
|
return { format: 1, path: relative, versions: [] };
|
||||||
|
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(directory: string, filename: string) {
|
||||||
|
const target = await this.target(directory, filename);
|
||||||
|
const [current, archive] = await Promise.all([
|
||||||
|
this.readText(target.file),
|
||||||
|
this.readArchive(target.archive, target.relative),
|
||||||
|
]);
|
||||||
|
const currentHash = hash(current);
|
||||||
|
const versions = archive.versions
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map(({ content, ...v }) => ({
|
||||||
|
...v,
|
||||||
|
size: Buffer.byteLength(content),
|
||||||
|
identical: v.hash === currentHash,
|
||||||
|
}));
|
||||||
|
return { versions, currentHash, limit: this.limits.versions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(directory: string, filename: string, id: string) {
|
||||||
|
const target = await this.target(directory, filename);
|
||||||
|
const [current, archive] = await Promise.all([
|
||||||
|
this.readText(target.file),
|
||||||
|
this.readArchive(target.archive, target.relative),
|
||||||
|
]);
|
||||||
|
const version = archive.versions.find((v) => v.id === id);
|
||||||
|
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||||
|
return { version, current, currentHash: hash(current) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
content: string,
|
||||||
|
options: SaveOptions = {},
|
||||||
|
) {
|
||||||
|
return this.mutate(directory, filename, { content, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
id: string,
|
||||||
|
expectedHash: string,
|
||||||
|
) {
|
||||||
|
return this.mutate(directory, filename, { id, expectedHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mutate(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
change:
|
||||||
|
| ({ content: string } & SaveOptions)
|
||||||
|
| { id: string; expectedHash: string },
|
||||||
|
) {
|
||||||
|
const target = await this.target(directory, filename);
|
||||||
|
const releaseFile = await lock(target.file, {
|
||||||
|
lockfilePath: getUniqueLockPath(target.file),
|
||||||
|
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let current: string;
|
||||||
|
try {
|
||||||
|
current = await this.readText(target.file);
|
||||||
|
if (
|
||||||
|
'content' in change &&
|
||||||
|
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
|
||||||
|
change.content.includes('\0'))
|
||||||
|
) {
|
||||||
|
throw new ScriptHistoryError(
|
||||||
|
413,
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!('content' in change) ||
|
||||||
|
!(error instanceof ScriptHistoryError) ||
|
||||||
|
error.status !== 413
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
// Hash oversized or non-UTF-8 files without reading them all into memory.
|
||||||
|
const digest = createHash('sha256');
|
||||||
|
for await (const chunk of createReadStream(target.file))
|
||||||
|
digest.update(chunk);
|
||||||
|
const currentHash = digest.digest('hex');
|
||||||
|
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
|
||||||
|
if (change.expectedHash !== currentHash)
|
||||||
|
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||||
|
await this.preparedWrite(target.file, change.content, (commit) =>
|
||||||
|
commit(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: change.content,
|
||||||
|
changed: true,
|
||||||
|
historyRecorded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
('id' in change || change.skipHistory) &&
|
||||||
|
hash(current) !== change.expectedHash
|
||||||
|
) {
|
||||||
|
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||||
|
}
|
||||||
|
if ('content' in change && change.content === current)
|
||||||
|
return { content: current, changed: false };
|
||||||
|
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
|
||||||
|
const releaseHistory = await lock(this.historyRoot, {
|
||||||
|
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const archive = await this.readArchive(target.archive, target.relative);
|
||||||
|
const restored =
|
||||||
|
'id' in change
|
||||||
|
? archive.versions.find((v) => v.id === change.id)
|
||||||
|
: undefined;
|
||||||
|
if ('id' in change && !restored)
|
||||||
|
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||||
|
const content =
|
||||||
|
'content' in change ? change.content : restored!.content;
|
||||||
|
if (content === current) return { content, changed: false };
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(content) > this.limits.fileBytes ||
|
||||||
|
content.includes('\0')
|
||||||
|
) {
|
||||||
|
throw new ScriptHistoryError(
|
||||||
|
413,
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentHash = hash(current);
|
||||||
|
if (
|
||||||
|
archive.versions[archive.versions.length - 1]?.hash !== currentHash
|
||||||
|
) {
|
||||||
|
const head =
|
||||||
|
archive.head?.hash === currentHash ? archive.head : undefined;
|
||||||
|
archive.versions.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: head?.createdAt || new Date().toISOString(),
|
||||||
|
source:
|
||||||
|
head?.source ||
|
||||||
|
(archive.versions.length ? 'external' : 'baseline'),
|
||||||
|
hash: currentHash,
|
||||||
|
content: current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Only prior contents are stored. Head metadata labels the next snapshot,
|
||||||
|
// but is used only if its hash matches the file. A failed file write can
|
||||||
|
// never introduce a phantom content version or lose the old snapshot.
|
||||||
|
archive.head = {
|
||||||
|
hash: hash(content),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
source: 'id' in change ? 'restore' : 'save',
|
||||||
|
};
|
||||||
|
const packed = await this.pack(archive);
|
||||||
|
const cleanupPending = await this.preparedWrite(
|
||||||
|
target.file,
|
||||||
|
content,
|
||||||
|
(commit) => this.store(target.archive, packed, commit),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
changed: true,
|
||||||
|
historyRecorded: true,
|
||||||
|
cleanupPending,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await releaseHistory();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await releaseFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pack(archive: Archive) {
|
||||||
|
archive.versions = archive.versions.slice(-this.limits.versions);
|
||||||
|
while (true) {
|
||||||
|
const serialized = Buffer.from(JSON.stringify(archive));
|
||||||
|
if (serialized.length <= this.limits.unpackedBytes) {
|
||||||
|
const compressed = await compress(serialized, {
|
||||||
|
params: {
|
||||||
|
[constants.BROTLI_PARAM_QUALITY]: 4,
|
||||||
|
[constants.BROTLI_PARAM_LGWIN]: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (compressed.length <= this.limits.archiveBytes) return compressed;
|
||||||
|
}
|
||||||
|
if (archive.versions.length <= 1)
|
||||||
|
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||||
|
archive.versions.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage the script before publishing history. A failed write/fsync never
|
||||||
|
* truncates the original. Preserve the existing file's ownership and mode. */
|
||||||
|
private async preparedWrite<T>(
|
||||||
|
file: string,
|
||||||
|
content: string,
|
||||||
|
run: (commit: () => Promise<void>) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const stat = await fs.stat(file);
|
||||||
|
const temporary = path.join(
|
||||||
|
path.dirname(file),
|
||||||
|
`.ql-save-${randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temporary, content, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
typeof process.getuid === 'function' &&
|
||||||
|
typeof process.getgid === 'function' &&
|
||||||
|
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
|
||||||
|
) {
|
||||||
|
await fs.chown(temporary, stat.uid, stat.gid);
|
||||||
|
}
|
||||||
|
await fs.chmod(temporary, stat.mode & 0o7777);
|
||||||
|
const handle = await fs.open(temporary, 'r');
|
||||||
|
try {
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return await run(() => fs.rename(temporary, file));
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(temporary).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async store(
|
||||||
|
file: string,
|
||||||
|
content: Buffer,
|
||||||
|
commit: () => Promise<void>,
|
||||||
|
) {
|
||||||
|
const entries = [];
|
||||||
|
for (const name of await fs.readdir(this.historyRoot)) {
|
||||||
|
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
|
||||||
|
const fullPath = path.join(this.historyRoot, name);
|
||||||
|
if (fullPath === file) continue;
|
||||||
|
const stat = await fs.lstat(fullPath);
|
||||||
|
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => a.time - b.time);
|
||||||
|
let total = entries.reduce((sum, v) => sum + v.size, content.length);
|
||||||
|
const victims: string[] = [];
|
||||||
|
while (
|
||||||
|
entries.length &&
|
||||||
|
(total > this.limits.totalBytes || entries.length >= this.limits.files)
|
||||||
|
) {
|
||||||
|
const oldest = entries.shift()!;
|
||||||
|
victims.push(oldest.file);
|
||||||
|
total -= oldest.size;
|
||||||
|
}
|
||||||
|
if (total > this.limits.totalBytes)
|
||||||
|
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||||
|
const previous = await fs.readFile(file).catch((error) => {
|
||||||
|
if (error.code === 'ENOENT') return undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const temporary = path.join(this.historyRoot, '.pending.tmp');
|
||||||
|
const publish = async (bytes: Buffer) => {
|
||||||
|
await fs.unlink(temporary).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
});
|
||||||
|
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
||||||
|
const handle = await fs.open(temporary, 'r');
|
||||||
|
try {
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
await fs.rename(temporary, file);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await publish(content);
|
||||||
|
try {
|
||||||
|
await commit();
|
||||||
|
} catch (error) {
|
||||||
|
// The original script is still intact. Restore its exact history before
|
||||||
|
// returning an error; no unrelated archive has been removed yet.
|
||||||
|
if (previous) await publish(previous);
|
||||||
|
else await fs.unlink(file);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Quota eviction is post-commit maintenance. Report cleanup failures as a
|
||||||
|
// warning, not a failed save (which would encourage a misleading retry).
|
||||||
|
let cleanupPending = false;
|
||||||
|
for (const victim of victims) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(victim);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'ENOENT') cleanupPending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanupPending;
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(temporary).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Quote one literal POSIX shell argument, including quotes and substitutions. */
|
||||||
|
export function shellQuote(value: string): string {
|
||||||
|
if (value.includes('\0')) throw new Error('Invalid shell argument');
|
||||||
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import lockfile from 'proper-lockfile';
|
||||||
|
import path from 'path';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
// Protect subscription storage ownership from the first database read through
|
||||||
|
// cleanup. Creation and URL/branch updates must use the same cross-process lock.
|
||||||
|
export async function withSubscriptionMutation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const target = `${path.resolve(config.scriptPath)}.subscriptions`;
|
||||||
|
let release: () => Promise<void>;
|
||||||
|
try {
|
||||||
|
release = await lockfile.lock(target, {
|
||||||
|
realpath: false,
|
||||||
|
stale: 30000,
|
||||||
|
update: 10000,
|
||||||
|
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error('Subscription configuration is busy', { cause }),
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { resolveFileAccess } from './fileAccess';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
export function assertSubscriptionAlias(alias: string): void {
|
||||||
|
if (
|
||||||
|
typeof alias !== 'string' ||
|
||||||
|
alias.length > 200 ||
|
||||||
|
!/^[^\s\\\p{C}]+$/u.test(alias) ||
|
||||||
|
alias.startsWith('~') ||
|
||||||
|
alias.split('/').some((part) => !part || part === '.' || part === '..')
|
||||||
|
) {
|
||||||
|
throw Object.assign(new Error('Invalid subscription alias'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep ordinary SSH names and use a reserved namespace for path-like aliases.
|
||||||
|
* The database alias and its script/log directories remain unchanged. */
|
||||||
|
export function getSubscriptionSshAlias(alias: string): string {
|
||||||
|
assertSubscriptionAlias(alias);
|
||||||
|
if (/^[\p{L}\p{N}_.+-]+$/u.test(alias) && !alias.endsWith('.config')) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
return `~sub_${createHash('sha256').update(alias).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubscriptionPath(
|
||||||
|
root: string,
|
||||||
|
alias: string,
|
||||||
|
suffix = '',
|
||||||
|
): string {
|
||||||
|
// Internal SSH filenames cannot collide with user-supplied aliases.
|
||||||
|
if (!/^~sub_[a-f0-9]{64}$/.test(alias)) {
|
||||||
|
assertSubscriptionAlias(
|
||||||
|
alias.startsWith('~global_') ? alias.slice(8) : alias,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = resolveFileAccess(root, [alias + suffix]);
|
||||||
|
if (!target)
|
||||||
|
throw Object.assign(new Error('Invalid subscription path'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Subscription } from '../data/subscription';
|
||||||
|
import { formatUrl } from '../config/subscription';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { resolveFileAccess } from './fileAccess';
|
||||||
|
|
||||||
|
// Storage namespaces come from URLs and branches, not user aliases. In
|
||||||
|
// particular, a legal Git directory can exceed the alias's 200-character limit.
|
||||||
|
export function isSubscriptionStorageName(name: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof name === 'string' &&
|
||||||
|
!!name &&
|
||||||
|
!path.isAbsolute(name) &&
|
||||||
|
!/[\0\r\n\\]/.test(name) &&
|
||||||
|
!name.split('/').some((part) => !part || part === '.' || part === '..')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubscriptionStoragePath(
|
||||||
|
root: string,
|
||||||
|
name: string,
|
||||||
|
): string | undefined {
|
||||||
|
const target = isSubscriptionStorageName(name)
|
||||||
|
? resolveFileAccess(root, [name])
|
||||||
|
: '';
|
||||||
|
if (!target) {
|
||||||
|
if (isSubscriptionStorageName(name)) {
|
||||||
|
try {
|
||||||
|
fs.lstatSync(path.resolve(root, name));
|
||||||
|
} catch (error: any) {
|
||||||
|
// A long query can make the Shell candidate unrepresentable while
|
||||||
|
// the CLI's pathname-based file exists. Use the actual filesystem
|
||||||
|
// limit: macOS permits Unicode names exceeding 255 UTF-8 bytes.
|
||||||
|
if (error.code === 'ENAMETOOLONG') return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('Invalid subscription path'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match shell/update.sh get_uniq_path and update_raw, including SCP URLs. */
|
||||||
|
export function getSubscriptionStorageNames(
|
||||||
|
doc: Subscription,
|
||||||
|
engine: 'shell' | 'cli' = 'shell',
|
||||||
|
): {
|
||||||
|
script: string;
|
||||||
|
repo?: string;
|
||||||
|
raw?: string;
|
||||||
|
} {
|
||||||
|
// Old imported rows may only have an alias and no URL.
|
||||||
|
if (!doc.url) return { script: doc.alias, repo: doc.alias };
|
||||||
|
const url = formatUrl(doc).url!;
|
||||||
|
const trimmed = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
const basename = trimmed.slice(trimmed.lastIndexOf('/') + 1);
|
||||||
|
const dot = basename.lastIndexOf('.');
|
||||||
|
const repo = dot < 0 ? basename : basename.slice(0, dot);
|
||||||
|
const parentUrl = engine === 'cli' ? trimmed : url;
|
||||||
|
const slash = parentUrl.lastIndexOf('/');
|
||||||
|
const parent =
|
||||||
|
engine === 'shell' && slash < 0 ? url : parentUrl.slice(0, slash);
|
||||||
|
const owner = parent.slice(parent.lastIndexOf('/') + 1);
|
||||||
|
const author = owner.slice(owner.lastIndexOf(':') + 1);
|
||||||
|
const stem = `${author.slice(author.lastIndexOf('.') + 1)}_${repo}`;
|
||||||
|
if (doc.type === 'file') {
|
||||||
|
const suffix =
|
||||||
|
engine === 'cli'
|
||||||
|
? path.extname(new URL(url).pathname)
|
||||||
|
: `.${url.slice(url.lastIndexOf('.') + 1)}`;
|
||||||
|
const raw = `${stem}${suffix}`;
|
||||||
|
return { script: `raw_${raw}`, raw };
|
||||||
|
}
|
||||||
|
const directory = doc.branch ? `${stem}_${doc.branch}` : stem;
|
||||||
|
return { script: directory, repo: directory };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A subscription may have run through either engine before being deleted.
|
||||||
|
export function getSubscriptionStorageCandidates(doc: Subscription) {
|
||||||
|
const candidates: ReturnType<typeof getSubscriptionStorageNames>[] = [];
|
||||||
|
for (const engine of ['shell', 'cli'] as const) {
|
||||||
|
try {
|
||||||
|
candidates.push(getSubscriptionStorageNames(doc, engine));
|
||||||
|
} catch {
|
||||||
|
// Historical rows may have an invalid SSH alias or missing credentials.
|
||||||
|
// Retain their URL-derived storage without needing an executable SSH URL.
|
||||||
|
try {
|
||||||
|
candidates.push(
|
||||||
|
getSubscriptionStorageNames(
|
||||||
|
{
|
||||||
|
url: doc.url,
|
||||||
|
alias: doc.alias,
|
||||||
|
branch: doc.branch,
|
||||||
|
type: doc.type === 'private-repo' ? 'public-repo' : doc.type,
|
||||||
|
},
|
||||||
|
engine,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// One engine's invalid URL must not hide the other engine's candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Application, Request } from 'express';
|
import express, { Application, Request } from 'express';
|
||||||
import { AuthDataType, SystemModel } from '../data/system';
|
import { AuthDataType, SystemModel } from '../data/system';
|
||||||
import { normalizeClientIp } from './clientIp';
|
import { normalizeClientIp } from './clientIp';
|
||||||
|
import { t } from './i18n';
|
||||||
|
|
||||||
const DEFAULT_TRUST_PROXY = 'loopback';
|
const DEFAULT_TRUST_PROXY = 'loopback';
|
||||||
|
|
||||||
@@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
|
|||||||
function validateTrustProxy(value: string): string {
|
function validateTrustProxy(value: string): string {
|
||||||
const setting = normalizeSetting(value);
|
const setting = normalizeSetting(value);
|
||||||
if (setting.length > 500 || /[\r\n]/.test(setting)) {
|
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) {
|
if (/^\d+$/.test(setting) && Number(setting) > 20) {
|
||||||
throw new Error('代理层数不能超过 20');
|
throw new Error(t('代理层数不能超过 20'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = express();
|
const probe = express();
|
||||||
@@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) {
|
|||||||
|
|
||||||
export async function updateTrustProxy(value: string) {
|
export async function updateTrustProxy(value: string) {
|
||||||
if (getEnvironmentSetting()) {
|
if (getEnvironmentSetting()) {
|
||||||
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
|
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustProxy = validateTrustProxy(value);
|
const trustProxy = validateTrustProxy(value);
|
||||||
@@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) {
|
|||||||
where: { type: AuthDataType.systemConfig },
|
where: { type: AuthDataType.systemConfig },
|
||||||
});
|
});
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error('系统配置不存在');
|
throw new Error(t('系统配置不存在'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const plain = doc.get({ plain: true });
|
const plain = doc.get({ plain: true });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from 'path';
|
|||||||
import { writeFile, open, chmod } from 'fs/promises';
|
import { writeFile, open, chmod } from 'fs/promises';
|
||||||
import { fileExist } from '../config/util';
|
import { fileExist } from '../config/util';
|
||||||
|
|
||||||
function getUniqueLockPath(filePath: string) {
|
export function getUniqueLockPath(filePath: string) {
|
||||||
const sanitizedPath = filePath
|
const sanitizedPath = filePath
|
||||||
.replace(/[<>:"/\\|?*]/g, '_')
|
.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
.replace(/^_/, '');
|
.replace(/^_/, '');
|
||||||
|
|||||||
+5
-16
@@ -2,30 +2,19 @@ import 'reflect-metadata';
|
|||||||
import OpenService from './services/open';
|
import OpenService from './services/open';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import LoggerInstance from './loaders/logger';
|
import LoggerInstance from './loaders/logger';
|
||||||
import config from './config';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import { writeFileWithLock } from './shared/utils';
|
|
||||||
|
|
||||||
const tokenFile = path.join(config.configPath, 'token.json');
|
|
||||||
|
|
||||||
async function getToken() {
|
async function getToken() {
|
||||||
try {
|
try {
|
||||||
Container.set('logger', LoggerInstance);
|
Container.set('logger', LoggerInstance);
|
||||||
const openService = Container.get(OpenService);
|
const openService = Container.get(OpenService);
|
||||||
const appToken = await openService.generateSystemToken();
|
const appToken = await openService.generateSystemToken(
|
||||||
|
process.argv.includes('--renew'),
|
||||||
|
);
|
||||||
console.log(appToken.value);
|
console.log(appToken.value);
|
||||||
await writeFile({
|
|
||||||
value: appToken.value,
|
|
||||||
expiration: appToken.expiration,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeFile(data: any) {
|
|
||||||
await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getToken();
|
getToken();
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
|
|||||||
.required()
|
.required()
|
||||||
.custom(validateSchedule)
|
.custom(validateSchedule)
|
||||||
.messages({
|
.messages({
|
||||||
|
'any.required': '定时规则不能为空',
|
||||||
'any.invalid': '无效的定时规则',
|
'any.invalid': '无效的定时规则',
|
||||||
'string.empty': '定时规则不能为空',
|
'string.empty': '定时规则不能为空',
|
||||||
|
'string.base': '定时规则必须是字符串',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const commonCronSchema = {
|
export const commonCronSchema = {
|
||||||
@@ -30,7 +32,27 @@ export const commonCronSchema = {
|
|||||||
schedule: scheduleSchema,
|
schedule: scheduleSchema,
|
||||||
labels: Joi.array().optional().allow(null),
|
labels: Joi.array().optional().allow(null),
|
||||||
sub_id: Joi.number().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_before: Joi.string().optional().allow('').allow(null),
|
||||||
task_after: Joi.string().optional().allow('').allow(null),
|
task_after: Joi.string().optional().allow('').allow(null),
|
||||||
log_name: Joi.string()
|
log_name: Joi.string()
|
||||||
|
|||||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
|||||||
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
|
# 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
|
### Direct access token
|
||||||
|
|
||||||
@@ -128,11 +128,11 @@ npm run test:cli
|
|||||||
node cli/scripts/verify-package.cjs
|
node cli/scripts/verify-package.cjs
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
||||||
|
|
||||||
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
|
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
|
||||||
|
|
||||||
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
|
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
|
||||||
|
|
||||||
## Panel-internal tools
|
## Panel-internal tools
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
|||||||
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
|
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
|
||||||
```
|
```
|
||||||
|
|
||||||
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
||||||
|
|
||||||
### 直接使用访问令牌
|
### 直接使用访问令牌
|
||||||
|
|
||||||
@@ -128,11 +128,11 @@ npm run test:cli
|
|||||||
node cli/scripts/verify-package.cjs
|
node cli/scripts/verify-package.cjs
|
||||||
```
|
```
|
||||||
|
|
||||||
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
||||||
|
|
||||||
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
||||||
|
|
||||||
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
|
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
|
||||||
|
|
||||||
## 面板内部工具
|
## 面板内部工具
|
||||||
|
|
||||||
|
|||||||
Generated
+78
-4
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ql": "dist/npm/ql.js"
|
"ql": "dist/npm/ql.js"
|
||||||
},
|
},
|
||||||
@@ -14,12 +15,12 @@
|
|||||||
"@types/node": "22.19.0",
|
"@types/node": "22.19.0",
|
||||||
"commander": "15.0.0",
|
"commander": "15.0.0",
|
||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
|
"tar": "7.5.20",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
},
|
}
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
@@ -463,6 +464,19 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.0",
|
"version": "22.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
|
||||||
@@ -473,6 +487,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "15.0.0",
|
"version": "15.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||||
@@ -525,6 +549,46 @@
|
|||||||
"@esbuild/win32-x64": "0.28.2"
|
"@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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||||
@@ -545,6 +609,16 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"description": "Authenticated remote management CLI for QingLong 2.x",
|
"description": "Authenticated remote management CLI for QingLong 2.x",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"commander": "15.0.0",
|
"commander": "15.0.0",
|
||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"@types/node": "22.19.0"
|
"@types/node": "22.19.0",
|
||||||
|
"tar": "7.5.20"
|
||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+26
-1
@@ -13,6 +13,29 @@ execFileSync(
|
|||||||
],
|
],
|
||||||
{ stdio: 'inherit' },
|
{ stdio: 'inherit' },
|
||||||
);
|
);
|
||||||
|
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({
|
require('esbuild').buildSync({
|
||||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||||
@@ -45,7 +68,9 @@ const remote = require('esbuild').buildSync({
|
|||||||
metafile: true,
|
metafile: true,
|
||||||
});
|
});
|
||||||
for (const input of Object.keys(remote.metafile.inputs)) {
|
for (const input of Object.keys(remote.metafile.inputs)) {
|
||||||
if (/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input))
|
if (
|
||||||
|
/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input)
|
||||||
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Local implementation leaked into remote npm bundle: ' + input,
|
'Local implementation leaked into remote npm bundle: ' + input,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# OpenAPI 全量参考 / Complete OpenAPI reference
|
# OpenAPI 全量参考 / Complete OpenAPI reference
|
||||||
|
|
||||||
CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||||
|
|
||||||
The catalogue covers 143 active routes in the current develop 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 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.
|
||||||
|
|
||||||
## 认证与权限 / Authentication and permissions
|
## 认证与权限 / Authentication and permissions
|
||||||
|
|
||||||
@@ -46,6 +46,9 @@ ql env create --data @envs.json --json
|
|||||||
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json
|
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json
|
||||||
ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
|
ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
|
||||||
ql script get --query '{"file":"demo.js","path":""}' --json
|
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
|
||||||
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
||||||
ql config get --query '{"path":"example.sh"}' --json
|
ql config get --query '{"path":"example.sh"}' --json
|
||||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
||||||
@@ -133,6 +136,9 @@ System/user operations below execute remotely through the panel API. They do not
|
|||||||
| `ql config save ` | POST | `configs/save` | --data, --query |
|
| `ql config save ` | POST | `configs/save` | --data, --query |
|
||||||
| `ql script list ` | GET | `scripts` | --query |
|
| `ql script list ` | GET | `scripts` | --query |
|
||||||
| `ql script get ` | GET | `scripts/detail` | --query |
|
| `ql script get ` | GET | `scripts/detail` | --query |
|
||||||
|
| `ql script history-list ` | GET | `scripts/history` | --query |
|
||||||
|
| `ql script history-detail ` | GET | `scripts/history/detail` | --query |
|
||||||
|
| `ql script history-restore ` | PUT | `scripts/history/restore` | --data, --query |
|
||||||
| `ql script create ` | POST | `scripts` | --data, --file (file), --query |
|
| `ql script create ` | POST | `scripts` | --data, --file (file), --query |
|
||||||
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
|
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
|
||||||
| `ql script delete ` | DELETE | `scripts` | --data, --query |
|
| `ql script delete ` | DELETE | `scripts` | --data, --query |
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
|
|||||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||||
"printf '%s\\\\n' '__sitecustomize__' &&",
|
"printf '%s\\\\n' '__sitecustomize__' &&",
|
||||||
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
|
'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 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], {",
|
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
|
||||||
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
||||||
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
|
|||||||
' console.log(output);',
|
' console.log(output);',
|
||||||
' }',
|
' }',
|
||||||
' if (task_before) {',
|
' if (task_before) {',
|
||||||
" console.log('执行前置命令结束\\n');",
|
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
|
||||||
' }',
|
' }',
|
||||||
' } catch (error) {',
|
' } catch (error) {',
|
||||||
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
||||||
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
|
|||||||
' // environment variable is too large',
|
' // environment variable is too large',
|
||||||
' }',
|
' }',
|
||||||
' if (task_before) {',
|
' 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", "")',
|
' task_before = os.getenv("task_before", "")',
|
||||||
' if 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',
|
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
|
||||||
'source "$file" "$@" &&',
|
'source "$file" "$@" &&',
|
||||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||||
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
|
|||||||
' if len(output) > 0:',
|
' if len(output) > 0:',
|
||||||
' print(output)',
|
' print(output)',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
'',
|
'',
|
||||||
' except subprocess.CalledProcessError as error:',
|
' except subprocess.CalledProcessError as error:',
|
||||||
' print(f"\\ue926 run task before error: {error}")',
|
' print(f"\\ue926 run task before error: {error}")',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
' except OSError as error:',
|
' except OSError as error:',
|
||||||
' error_message = str(error)',
|
' error_message = str(error)',
|
||||||
' if "Argument list too long" not in error_message:',
|
' if "Argument list too long" not in error_message:',
|
||||||
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
|
|||||||
' # else:',
|
' # else:',
|
||||||
' # environment variable is too large',
|
' # environment variable is too large',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
' except Exception as error:',
|
' except Exception as error:',
|
||||||
' print(f"\\ue926 run task before error: {error}")',
|
' print(f"\\ue926 run task before error: {error}")',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
'',
|
'',
|
||||||
' import task_before',
|
' import task_before',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -70,10 +70,26 @@ if [ -n "$__ql_timeout" ]; then
|
|||||||
fi
|
fi
|
||||||
if [ "$__ql_command" = true ]; then
|
if [ "$__ql_command" = true ]; then
|
||||||
"\${__ql_args[@]}" "\${__ql_script_args[@]}"
|
"\${__ql_args[@]}" "\${__ql_script_args[@]}"
|
||||||
|
_task_exit_code=$?
|
||||||
else
|
else
|
||||||
. "\${__ql_args[0]}" "\${__ql_script_args[@]}"
|
# Match task.sh: keep script exit/exec/options inside a child shell while
|
||||||
|
# retaining before-hook variables and functions for the script.
|
||||||
|
# Keep the supervisor alive until the child's signal/EXIT traps finish, but
|
||||||
|
# do not turn user cancellation into an ordinary completion with after hooks.
|
||||||
|
__ql_saved_signals=$(trap -p INT TERM HUP QUIT ALRM TSTP)
|
||||||
|
__ql_cancel_code=''
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l INT)))' INT
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l TERM)))' TERM
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l HUP)))' HUP
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l QUIT)))' QUIT
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l ALRM)))' ALRM
|
||||||
|
trap '__ql_cancel_code=$((128 + $(kill -l TSTP)))' TSTP
|
||||||
|
( . "\${__ql_args[0]}" "\${__ql_script_args[@]}" )
|
||||||
|
_task_exit_code=$?
|
||||||
|
trap - INT TERM HUP QUIT ALRM TSTP
|
||||||
|
eval "$__ql_saved_signals"
|
||||||
|
if [ -n "$__ql_cancel_code" ] && ! [ -f "$__ql_timeout" ]; then exit "$__ql_cancel_code"; fi
|
||||||
fi
|
fi
|
||||||
_task_exit_code=$?
|
|
||||||
if [ -n "$__ql_timeout" ] && [ -f "$__ql_timeout" ]; then _task_exit_code=124; fi
|
if [ -n "$__ql_timeout" ] && [ -f "$__ql_timeout" ]; then _task_exit_code=124; fi
|
||||||
__ql_run_after
|
__ql_run_after
|
||||||
exit "$_task_exit_code"
|
exit "$_task_exit_code"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
|
||||||
|
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
|
||||||
|
const armv6ServerArgs = Object.freeze([
|
||||||
|
'--no-opt',
|
||||||
|
'--no-sparkplug',
|
||||||
|
'--no-maglev',
|
||||||
|
]);
|
||||||
|
function serverRuntimeArgs(runtime = process) {
|
||||||
|
return runtime.arch === 'arm' &&
|
||||||
|
Number(runtime.config.variables.arm_version) === 6 &&
|
||||||
|
Number(runtime.versions.node.split('.')[0]) >= 24
|
||||||
|
? [...armv6ServerArgs]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { serverRuntimeArgs, armv6ServerArgs };
|
||||||
|
if (require.main === module)
|
||||||
|
process.stdout.write(serverRuntimeArgs().join(' '));
|
||||||
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
|
|||||||
import { atomicWrite } from '../runtime/files';
|
import { atomicWrite } from '../runtime/files';
|
||||||
import { runProcess, checkedProcess } from '../runtime/process';
|
import { runProcess, checkedProcess } from '../runtime/process';
|
||||||
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
||||||
|
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
|
||||||
|
serverRuntimeArgs(): string[];
|
||||||
|
armv6ServerArgs: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function repairConfiguration(
|
export async function repairConfiguration(
|
||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
@@ -117,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
|
|||||||
for (const line of result.stdout.split('\n')) {
|
for (const line of result.stdout.split('\n')) {
|
||||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||||
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
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]);
|
const pid = Number(match[1]);
|
||||||
if (pid === process.pid) continue;
|
if (pid === process.pid) continue;
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +158,14 @@ export async function startPanel(
|
|||||||
GRPC_PORT: context.env.QlGrpcPort || '5500',
|
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)) {
|
if (await executableAvailable('pm2', context.env)) {
|
||||||
const flushed = await runProcess('pm2', ['flush'], options);
|
const flushed = await runProcess('pm2', ['flush'], options);
|
||||||
if (flushed.code === 0) {
|
if (flushed.code === 0) {
|
||||||
@@ -171,7 +187,7 @@ export async function startPanel(
|
|||||||
0o600,
|
0o600,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const child = spawn(process.execPath, [entry], {
|
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
|
||||||
...options,
|
...options,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ['ignore', log.fd, log.fd],
|
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,
|
||||||
|
};
|
||||||
@@ -8,12 +8,42 @@ import { checkedProcess } from '../runtime/process';
|
|||||||
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
||||||
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
||||||
|
|
||||||
|
// The Bash image updater and local CLI must enforce the same build provenance.
|
||||||
|
const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||||
|
releaseBranch(env: NodeJS.ProcessEnv): string;
|
||||||
|
verifyUpgrade(
|
||||||
|
source: string,
|
||||||
|
staticRoot: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<unknown>;
|
||||||
|
waitForHealth(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
timeout?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void>;
|
||||||
|
withUpgradeLock<T>(
|
||||||
|
tmp: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<T>;
|
||||||
|
stageUpgrade(options: {
|
||||||
|
root: string;
|
||||||
|
tmp: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
mirror: 'github' | 'gitee';
|
||||||
|
signal?: AbortSignal;
|
||||||
|
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
|
||||||
|
install(source: string): Promise<void>;
|
||||||
|
}): Promise<{ source: string; static: string }>;
|
||||||
|
selectedUpgrade(
|
||||||
|
tmp: string,
|
||||||
|
branch: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<{ source: string; static: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export function releaseBranch(context: LocalContext): string {
|
export function releaseBranch(context: LocalContext): string {
|
||||||
return ['develop', 'debian', 'debian-dev'].includes(
|
return artifacts.releaseBranch(context.env);
|
||||||
context.env.QL_BRANCH ?? '',
|
|
||||||
)
|
|
||||||
? context.env.QL_BRANCH!
|
|
||||||
: 'master';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateTree(
|
async function validateTree(
|
||||||
@@ -48,139 +78,36 @@ export async function stageUpgrade(
|
|||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
mirror: 'github' | 'gitee',
|
mirror: 'github' | 'gitee',
|
||||||
): Promise<{ source: string; static: string }> {
|
): Promise<{ source: string; static: string }> {
|
||||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
return artifacts.withUpgradeLock(
|
||||||
const workspace = await fs.mkdtemp(
|
context.paths.dir_tmp!,
|
||||||
path.join(context.paths.dir_tmp!, 'upgrade-'),
|
() =>
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
const branch = releaseBranch(context);
|
|
||||||
try {
|
|
||||||
for (const repository of ['qinglong', 'qinglong-static']) {
|
|
||||||
const url =
|
|
||||||
mirror === 'github'
|
|
||||||
? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip`
|
|
||||||
: `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`;
|
|
||||||
const archive = path.join(workspace, `${repository}.zip`);
|
|
||||||
await checkedProcess(
|
|
||||||
'curl',
|
|
||||||
[
|
|
||||||
'--fail',
|
|
||||||
'--location',
|
|
||||||
'--proto',
|
|
||||||
'=https',
|
|
||||||
'--proto-redir',
|
|
||||||
'=https',
|
|
||||||
'--output',
|
|
||||||
archive,
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
{ env: context.env },
|
|
||||||
);
|
|
||||||
const listing = await checkedProcess('unzip', ['-Z1', archive], {
|
|
||||||
env: context.env,
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
const expected = `${repository}-${branch}`;
|
|
||||||
for (const name of listing.stdout.split('\n').filter(Boolean)) {
|
|
||||||
if (
|
|
||||||
name.includes('\\') ||
|
|
||||||
name.includes('\r') ||
|
|
||||||
name.startsWith('/') ||
|
|
||||||
name.split('/').includes('..') ||
|
|
||||||
name.split('/')[0] !== expected
|
|
||||||
)
|
|
||||||
throw new Error(translate(context.env, '升级归档包含无效路径。'));
|
|
||||||
}
|
|
||||||
// Reject Unix symlinks before extraction, including links that could redirect
|
|
||||||
// a later archive entry outside the staging directory.
|
|
||||||
const modes = await checkedProcess('unzip', ['-Z', '-l', archive], {
|
|
||||||
env: context.env,
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
|
||||||
throw new Error(translate(context.env, '升级归档包含符号链接。'));
|
|
||||||
await checkedProcess('unzip', ['-oq', archive, '-d', workspace], {
|
|
||||||
env: context.env,
|
|
||||||
});
|
|
||||||
await validateTree(path.join(workspace, expected), context.env);
|
|
||||||
}
|
|
||||||
const source = path.join(workspace, `qinglong-${branch}`);
|
|
||||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
|
||||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
|
||||||
const manifest = await fs.readFile(path.join(source, 'package.json'));
|
|
||||||
const existing = await fs.readFile(path.join(context.root, 'package.json'));
|
|
||||||
if (!manifest.equals(existing))
|
|
||||||
await installPanelDependencies(context, source);
|
|
||||||
// Publish a marker only after both archives and dependency installation succeed.
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(workspace, 'ready.json'),
|
|
||||||
JSON.stringify({ source, static: staticRoot }),
|
|
||||||
{ 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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await fs.rename(
|
|
||||||
pointer,
|
|
||||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
|
||||||
);
|
|
||||||
return { source, static: staticRoot };
|
|
||||||
} catch (error) {
|
|
||||||
await fs.rm(workspace, { recursive: true, force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectedUpgrade(
|
async function selectedUpgrade(
|
||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
): Promise<{ source: string; static: string }> {
|
): Promise<{ source: string; static: string }> {
|
||||||
const branch = releaseBranch(context);
|
return artifacts.selectedUpgrade(
|
||||||
const fallback = {
|
context.paths.dir_tmp!,
|
||||||
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
|
releaseBranch(context),
|
||||||
static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`),
|
context.env,
|
||||||
};
|
|
||||||
const pointer = await fs
|
|
||||||
.readFile(
|
|
||||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
.catch((error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code === 'ENOENT') return undefined;
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
if (pointer === undefined) return fallback;
|
|
||||||
const directory: unknown = JSON.parse(pointer)?.directory;
|
|
||||||
if (
|
|
||||||
typeof directory !== 'string' ||
|
|
||||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(directory)
|
|
||||||
)
|
|
||||||
throw new Error(translate(context.env, '暂存升级指针无效。'));
|
|
||||||
const workspace = path.join(context.paths.dir_tmp!, directory);
|
|
||||||
const selected = {
|
|
||||||
source: path.join(workspace, `qinglong-${branch}`),
|
|
||||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
|
||||||
};
|
|
||||||
for (const location of [workspace, selected.source, selected.static]) {
|
|
||||||
const stat = await fs.lstat(location);
|
|
||||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
||||||
throw new Error(translate(context.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(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
|
|
||||||
return selected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReloadLifecycle {
|
export interface ReloadLifecycle {
|
||||||
stop(context: LocalContext): Promise<void>;
|
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.
|
// All old entries stay on the same filesystem until service start succeeds.
|
||||||
@@ -275,7 +202,7 @@ export async function replaceAndReload(
|
|||||||
if (item.existed) await fs.rename(item.backup, item.target);
|
if (item.existed) await fs.rename(item.backup, item.target);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await lifecycle.start(context);
|
await lifecycle.start(context, true);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
translate(
|
translate(
|
||||||
@@ -345,8 +272,7 @@ export async function reloadPanel(
|
|||||||
const staticRoot = selected.static;
|
const staticRoot = selected.static;
|
||||||
await validateTree(source, context.env);
|
await validateTree(source, context.env);
|
||||||
await validateTree(staticRoot, context.env);
|
await validateTree(staticRoot, context.env);
|
||||||
await fs.access(path.join(source, 'package.json'));
|
await artifacts.verifyUpgrade(source, staticRoot, context.env);
|
||||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
|
||||||
const entries = await fs.readdir(source);
|
const entries = await fs.readdir(source);
|
||||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||||
const relativeData = path.relative(context.root, context.data);
|
const relativeData = path.relative(context.root, context.data);
|
||||||
@@ -371,7 +297,36 @@ export async function reloadPanel(
|
|||||||
if (selection)
|
if (selection)
|
||||||
replacements.push({ source: selection.source, target: selection.target });
|
replacements.push({ source: selection.source, target: selection.target });
|
||||||
try {
|
try {
|
||||||
return await replaceAndReload(context, replacements);
|
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 {
|
} finally {
|
||||||
if (selection)
|
if (selection)
|
||||||
await fs.rm(selection.directory, { recursive: true, force: true });
|
await fs.rm(selection.directory, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -0,0 +1,979 @@
|
|||||||
|
// Shared by the image's Bash updater and the optional local CLI. Use only
|
||||||
|
// installed code to validate downloads; never execute a downloaded verifier.
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createHash, randomUUID } = require('node:crypto');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const http = require('node:http');
|
||||||
|
const { setTimeout: delay } = require('node:timers/promises');
|
||||||
|
|
||||||
|
const upgradeEnglish = {
|
||||||
|
'更新包下载及校验成功...': 'Update package downloaded and verified...',
|
||||||
|
'更新及启动检查成功...':
|
||||||
|
'Update and startup checks completed successfully...',
|
||||||
|
'更新失败: %s': 'Update failed: %s',
|
||||||
|
'构建清单无效。': 'Invalid or dirty build manifest',
|
||||||
|
'缺少构建产物: %s': 'Missing build output: %s',
|
||||||
|
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
|
||||||
|
'构建包含非法文件: %s': 'Unsupported build entry: %s',
|
||||||
|
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
|
||||||
|
'已有更新或重载正在执行。': 'Another upgrade is running',
|
||||||
|
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
|
||||||
|
'升级归档路径无效。': 'Invalid archive paths',
|
||||||
|
'升级归档包含符号链接。': 'Archive contains symlinks',
|
||||||
|
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
|
||||||
|
'暂存目录无效。': 'Invalid staging directory',
|
||||||
|
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
|
||||||
|
'运行依赖已变化,请重新下载更新。':
|
||||||
|
'Installed dependencies changed; download the update again',
|
||||||
|
'保留备份: %s': 'Retained backup: %s',
|
||||||
|
'后端启动检查失败。': 'Backend failed health check',
|
||||||
|
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
|
||||||
|
};
|
||||||
|
|
||||||
|
function upgradeMessage(key, env = process.env, ...values) {
|
||||||
|
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
|
||||||
|
let index = 0;
|
||||||
|
return template.replace(/%s/g, () => String(values[index++] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
const releaseBranch = (env) =>
|
||||||
|
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
||||||
|
|
||||||
|
async function manifestAt(staticRoot, env = process.env) {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
manifest.version !== 1 ||
|
||||||
|
manifest.dirty !== false ||
|
||||||
|
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
|
||||||
|
!manifest.files ||
|
||||||
|
typeof manifest.files !== 'object' ||
|
||||||
|
Array.isArray(manifest.files)
|
||||||
|
)
|
||||||
|
throw new Error(upgradeMessage('构建清单无效。', env));
|
||||||
|
for (const required of ['build/app.js', 'dist/index.html'])
|
||||||
|
if (!Object.hasOwn(manifest.files, required))
|
||||||
|
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(upgradeMessage('源码与构建依赖不一致。', env));
|
||||||
|
await fs.access(path.join(source, 'package.json'));
|
||||||
|
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
||||||
|
const files = {};
|
||||||
|
async function visit(directory, prefix = '') {
|
||||||
|
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||||
|
const name = prefix + entry.name;
|
||||||
|
if (name === 'build-info.json') continue;
|
||||||
|
const filename = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
||||||
|
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
||||||
|
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(staticRoot);
|
||||||
|
const actual = Object.keys(files).sort(),
|
||||||
|
expected = Object.keys(manifest.files).sort();
|
||||||
|
if (
|
||||||
|
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
||||||
|
actual.some((name) => files[name] !== manifest.files[name])
|
||||||
|
)
|
||||||
|
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withUpgradeLock(tmp, operation, env = process.env) {
|
||||||
|
await fs.mkdir(tmp, { recursive: true });
|
||||||
|
const lock = path.join(tmp, 'upgrade.lock');
|
||||||
|
const token = randomUUID();
|
||||||
|
const owner = `owner-${process.pid}-${token}`;
|
||||||
|
const candidate = path.join(tmp, `upgrade-lock-${token}`);
|
||||||
|
let acquired = false;
|
||||||
|
await fs.mkdir(candidate, { mode: 0o700 });
|
||||||
|
try {
|
||||||
|
// Publish a nonempty directory atomically: a live lock can never be
|
||||||
|
// replaced, and a crash before publication cannot leave an ownerless lock.
|
||||||
|
await fs.writeFile(path.join(candidate, owner), '', {
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await fs.rename(candidate, lock);
|
||||||
|
acquired = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
||||||
|
}
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(lock);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!entries.length) continue;
|
||||||
|
const heldOwner = entries[0];
|
||||||
|
const match = /^owner-([1-9]\d*)-[a-f0-9-]{36}$/.exec(heldOwner);
|
||||||
|
let pid = match ? Number(match[1]) : 0;
|
||||||
|
if (entries.length === 1 && heldOwner === 'pid') {
|
||||||
|
try {
|
||||||
|
pid = Number(await fs.readFile(path.join(lock, heldOwner), 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stale = false;
|
||||||
|
if (
|
||||||
|
entries.length === 1 &&
|
||||||
|
Number.isSafeInteger(pid) &&
|
||||||
|
pid > 0 &&
|
||||||
|
pid <= 0x7fffffff
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ESRCH') stale = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stale)
|
||||||
|
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.
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(lock, heldOwner));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
if (acquired) {
|
||||||
|
await fs.unlink(path.join(lock, owner)).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
});
|
||||||
|
await fs.rmdir(lock).catch((error) => {
|
||||||
|
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code))
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fs.rm(candidate, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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://codeload.github.com/whyour/${repository}/zip/${ref}`
|
||||||
|
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||||
|
const download = async (target, expected) => {
|
||||||
|
await report({
|
||||||
|
phase: 'downloading',
|
||||||
|
artifact: repository,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: expected?.size || null,
|
||||||
|
percent: expected ? 0 : null,
|
||||||
|
});
|
||||||
|
await downloadWithProgress(
|
||||||
|
run,
|
||||||
|
archive,
|
||||||
|
target,
|
||||||
|
repository,
|
||||||
|
report,
|
||||||
|
Boolean(onProgress),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await download(
|
||||||
|
url,
|
||||||
|
mirror === 'github' ? info?.archives[repository] : undefined,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
|
||||||
|
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
|
||||||
|
await download(
|
||||||
|
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await report({
|
||||||
|
phase: 'extracting',
|
||||||
|
artifact: repository,
|
||||||
|
percent: null,
|
||||||
|
});
|
||||||
|
const listing = await run('unzip', ['-Z1', archive], true);
|
||||||
|
const expected = `${repository}-${ref}`;
|
||||||
|
const names = listing.stdout.split('\n').filter(Boolean);
|
||||||
|
if (
|
||||||
|
!names.length ||
|
||||||
|
names.some(
|
||||||
|
(name) =>
|
||||||
|
name.includes('\\') ||
|
||||||
|
name.includes('\r') ||
|
||||||
|
name.startsWith('/') ||
|
||||||
|
name.split('/').includes('..') ||
|
||||||
|
name.split('/')[0] !== expected,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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(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 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 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
|
||||||
|
.readFile(path.join(root, name))
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
});
|
||||||
|
if (!(await fs.readFile(path.join(source, name))).equals(current))
|
||||||
|
dependenciesChanged = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await fs.stat(path.join(root, 'node_modules')).catch(() => undefined))
|
||||||
|
)
|
||||||
|
dependenciesChanged = true;
|
||||||
|
if (dependenciesChanged) {
|
||||||
|
await report({ phase: 'dependencies', percent: null });
|
||||||
|
await install(source);
|
||||||
|
await fs.access(path.join(source, 'node_modules'));
|
||||||
|
await verifyUpgrade(source, staticRoot, env);
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const selected = { source, static: staticRoot };
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, 'ready.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
...selected,
|
||||||
|
root: path.resolve(root),
|
||||||
|
sourceCommit: manifest.sourceCommit,
|
||||||
|
dependenciesChanged,
|
||||||
|
}),
|
||||||
|
{ 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 },
|
||||||
|
);
|
||||||
|
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
|
||||||
|
const previous = await fs
|
||||||
|
.readFile(readyPointer, 'utf8')
|
||||||
|
.catch(() => undefined);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
await fs.rename(pointer, readyPointer);
|
||||||
|
// Retire only our previous successful stage, after publishing its replacement.
|
||||||
|
try {
|
||||||
|
const directory = previous && JSON.parse(previous).directory;
|
||||||
|
if (
|
||||||
|
typeof directory === 'string' &&
|
||||||
|
/^upgrade-[A-Za-z0-9_-]+$/.test(directory) &&
|
||||||
|
directory !== path.basename(workspace)
|
||||||
|
)
|
||||||
|
await fs.rm(path.join(tmp, directory), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Cleanup cannot invalidate the newly published stage. */
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(workspace, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
typeof pointer.directory !== 'string' ||
|
||||||
|
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
||||||
|
)
|
||||||
|
throw new Error(upgradeMessage('暂存升级指针无效。', env));
|
||||||
|
const workspace = path.join(tmp, pointer.directory);
|
||||||
|
const selected = {
|
||||||
|
source: path.join(workspace, `qinglong-${branch}`),
|
||||||
|
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||||
|
};
|
||||||
|
for (const directory of [workspace, selected.source, selected.static]) {
|
||||||
|
const stat = await fs.lstat(directory);
|
||||||
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||||
|
throw new Error(upgradeMessage('暂存目录无效。', env));
|
||||||
|
}
|
||||||
|
const ready = JSON.parse(
|
||||||
|
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||||
|
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||||
|
const manifest = metadataOnly
|
||||||
|
? await manifestAt(selected.static, env)
|
||||||
|
: await verifyUpgrade(selected.source, selected.static, env);
|
||||||
|
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||||
|
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, env = process.env) {
|
||||||
|
const changed = [];
|
||||||
|
let attemptedStart = false;
|
||||||
|
lifecycle.signal?.throwIfAborted();
|
||||||
|
try {
|
||||||
|
await lifecycle.stop();
|
||||||
|
for (const { source, target } of replacements) {
|
||||||
|
lifecycle.signal?.throwIfAborted();
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
const backup = `${target}.ql-backup-${randomUUID()}`;
|
||||||
|
const existed = !!(await fs.lstat(target).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
return undefined;
|
||||||
|
}));
|
||||||
|
if (existed) {
|
||||||
|
try {
|
||||||
|
await fs.rename(target, backup);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EXDEV') throw error;
|
||||||
|
try {
|
||||||
|
await fs.cp(target, backup, {
|
||||||
|
recursive: true,
|
||||||
|
verbatimSymlinks: true,
|
||||||
|
});
|
||||||
|
} catch (copyError) {
|
||||||
|
await fs.rm(backup, { recursive: true, force: true });
|
||||||
|
throw copyError;
|
||||||
|
}
|
||||||
|
changed.push({ target, backup, existed });
|
||||||
|
await fs.rm(target, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.at(-1)?.target !== target)
|
||||||
|
changed.push({ target, backup, existed });
|
||||||
|
await fs.cp(source, target, {
|
||||||
|
recursive: true,
|
||||||
|
verbatimSymlinks: true,
|
||||||
|
force: false,
|
||||||
|
errorOnExist: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lifecycle.signal?.throwIfAborted();
|
||||||
|
attemptedStart = true;
|
||||||
|
await lifecycle.start();
|
||||||
|
lifecycle.signal?.throwIfAborted();
|
||||||
|
} catch (error) {
|
||||||
|
// Do not modify files under a still-running failed startup. Keep backups
|
||||||
|
// for recovery if stopping the new process itself fails.
|
||||||
|
if (attemptedStart) await lifecycle.stop(true);
|
||||||
|
for (const item of changed.reverse()) {
|
||||||
|
await fs.rm(item.target, { recursive: true, force: true });
|
||||||
|
if (item.existed) {
|
||||||
|
try {
|
||||||
|
await fs.rename(item.backup, item.target);
|
||||||
|
} catch (restoreError) {
|
||||||
|
if (restoreError.code !== 'EXDEV') throw restoreError;
|
||||||
|
await fs.cp(item.backup, item.target, {
|
||||||
|
recursive: true,
|
||||||
|
verbatimSymlinks: true,
|
||||||
|
});
|
||||||
|
await fs.rm(item.backup, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await lifecycle.start(true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
for (const item of changed)
|
||||||
|
if (item.existed)
|
||||||
|
await fs
|
||||||
|
.rm(item.backup, { recursive: true, force: true })
|
||||||
|
.catch(() =>
|
||||||
|
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadSystem({
|
||||||
|
root,
|
||||||
|
data,
|
||||||
|
tmp,
|
||||||
|
staticRoot,
|
||||||
|
config,
|
||||||
|
env,
|
||||||
|
lifecycle,
|
||||||
|
additionalReplacements = [],
|
||||||
|
}) {
|
||||||
|
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))
|
||||||
|
reserved.add(relative.split(path.sep)[0]);
|
||||||
|
const replacements = (await fs.readdir(selected.source))
|
||||||
|
.filter((name) => !reserved.has(name))
|
||||||
|
.map((name) => ({
|
||||||
|
source: path.join(selected.source, name),
|
||||||
|
target: path.join(root, name),
|
||||||
|
}));
|
||||||
|
replacements.push(
|
||||||
|
{ source: selected.static, target: staticRoot },
|
||||||
|
{
|
||||||
|
source: path.join(selected.source, 'sample/config.sample.sh'),
|
||||||
|
target: path.join(config, 'config.sample.sh'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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`), {
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await fs
|
||||||
|
.rm(path.dirname(selected.source), { recursive: true, force: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startupHealthTimeout(runtime = process) {
|
||||||
|
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
|
||||||
|
// slow devices time to become healthy before deciding to restore a backup.
|
||||||
|
return runtime.arch === 'arm' &&
|
||||||
|
Number(runtime.config.variables.arm_version) === 6
|
||||||
|
? 300000
|
||||||
|
: 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
|
||||||
|
const port = Number(env.QlPort || 5700);
|
||||||
|
let base = env.QlBaseUrl || '';
|
||||||
|
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||||
|
if (base.endsWith('/')) base = base.slice(0, -1);
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const healthy = await new Promise((resolve) => {
|
||||||
|
let timer;
|
||||||
|
const finish = (healthy) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(healthy);
|
||||||
|
};
|
||||||
|
const req = http.get(
|
||||||
|
{ hostname: '127.0.0.1', port, path: `${base}/api/health`, signal },
|
||||||
|
(res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 65536) req.destroy();
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(body);
|
||||||
|
finish(
|
||||||
|
res.statusCode === 200 &&
|
||||||
|
result.code === 200 &&
|
||||||
|
result.data?.status === 'ok',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('error', () => finish(false));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Socket inactivity timeouts do not bound a response that keeps streaming.
|
||||||
|
timer = setTimeout(
|
||||||
|
() => req.destroy(new Error('Health probe timed out')),
|
||||||
|
Math.min(1000, Math.max(1, deadline - Date.now())),
|
||||||
|
);
|
||||||
|
req.on('error', () => finish(false));
|
||||||
|
});
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (healthy) return;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining > 0) {
|
||||||
|
try {
|
||||||
|
await delay(Math.min(500, remaining), undefined, { signal });
|
||||||
|
} catch (error) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(upgradeMessage('后端启动检查失败。', env));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProcess(env, program, args, capture = false, signal) {
|
||||||
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(program, args, {
|
||||||
|
env,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
// Pipes keep close pending until children have released their output.
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let aborted = false;
|
||||||
|
let failure;
|
||||||
|
let escalation;
|
||||||
|
const kill = (name) => {
|
||||||
|
if (!child.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(
|
||||||
|
process.platform === 'win32' ? child.pid : -child.pid,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EPERM') {
|
||||||
|
child.kill(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.code !== 'ESRCH') throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const abort = () => {
|
||||||
|
if (aborted) return;
|
||||||
|
aborted = true;
|
||||||
|
kill('SIGTERM');
|
||||||
|
escalation = setTimeout(() => kill('SIGKILL'), 1000);
|
||||||
|
escalation.unref();
|
||||||
|
};
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
if (capture) stdout += chunk;
|
||||||
|
else process.stdout.write(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
||||||
|
child.once('error', (error) => {
|
||||||
|
failure = error;
|
||||||
|
});
|
||||||
|
child.once('exit', () => {
|
||||||
|
// The shell can exit before a descendant that ignores SIGTERM.
|
||||||
|
if (aborted) kill('SIGKILL');
|
||||||
|
});
|
||||||
|
child.once('close', (code) => {
|
||||||
|
if (aborted) kill('SIGKILL');
|
||||||
|
clearTimeout(escalation);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (aborted) reject(signal.reason);
|
||||||
|
else if (failure) reject(failure);
|
||||||
|
else if (code === 0) resolve({ stdout });
|
||||||
|
else reject(new Error(`${program} failed (exit ${code})`));
|
||||||
|
});
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withLifecycleHooks(root, tmp, env, operation) {
|
||||||
|
const helpers = await fs.readFile(path.join(root, 'shell/share.sh'), 'utf8');
|
||||||
|
const environment = await fs.readFile(
|
||||||
|
path.join(root, 'shell/env.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const directory = await fs.mkdtemp(path.join(tmp, 'upgrade-hooks-'));
|
||||||
|
try {
|
||||||
|
// Keep the installed functions available through replacement and rollback.
|
||||||
|
// Their pkill patterns must never become part of the bash command line.
|
||||||
|
const snapshot = path.join(directory, 'lifecycle.sh');
|
||||||
|
await fs.writeFile(
|
||||||
|
snapshot,
|
||||||
|
`${helpers}\n${environment}\nimport_config
|
||||||
|
case "$1" in
|
||||||
|
stop) delete_pm2 ;;
|
||||||
|
start) reload_pm2 ;;
|
||||||
|
install) npm_install_2 "$2"; exit "$exit_status" ;;
|
||||||
|
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Offline selection and worker ownership belong to this operation only.
|
||||||
|
// Passing them to PM2 would pin future online updates to the old local file.
|
||||||
|
const lifecycleEnv = { ...env };
|
||||||
|
for (const name of [
|
||||||
|
'QL_UPGRADE_FILE',
|
||||||
|
'QL_UPGRADE_REPAIR_DEPS',
|
||||||
|
'QL_UPGRADE_LAUNCHER_PID',
|
||||||
|
])
|
||||||
|
delete lifecycleEnv[name];
|
||||||
|
const hook = (operation, args = [], signal) =>
|
||||||
|
runProcess(
|
||||||
|
lifecycleEnv,
|
||||||
|
'bash',
|
||||||
|
['--', snapshot, operation, ...args],
|
||||||
|
false,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return await operation(hook);
|
||||||
|
} finally {
|
||||||
|
await fs
|
||||||
|
.rm(directory, { recursive: true, force: true })
|
||||||
|
.catch(() =>
|
||||||
|
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const env = process.env,
|
||||||
|
root = env.QL_DIR;
|
||||||
|
if (!root || !path.isAbsolute(root))
|
||||||
|
throw new Error('QL_DIR must be absolute.');
|
||||||
|
const tmp = env.dir_tmp || path.join(root, '.tmp');
|
||||||
|
const controller = new AbortController();
|
||||||
|
for (const name of ['SIGINT', 'SIGTERM'])
|
||||||
|
process.on(name, () =>
|
||||||
|
controller.abort(new Error(`Upgrade interrupted (${name})`)),
|
||||||
|
);
|
||||||
|
const run = (program, args, capture) =>
|
||||||
|
runProcess(env, program, args, capture, controller.signal);
|
||||||
|
await withUpgradeLock(
|
||||||
|
tmp,
|
||||||
|
() =>
|
||||||
|
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||||
|
let job;
|
||||||
|
const lifecycle = {
|
||||||
|
signal: controller.signal,
|
||||||
|
stop: async () => {
|
||||||
|
await job?.onStop();
|
||||||
|
await hook('stop');
|
||||||
|
},
|
||||||
|
start: async (recover) => {
|
||||||
|
await hook('start', [], recover ? undefined : controller.signal);
|
||||||
|
await waitForHealth(
|
||||||
|
env,
|
||||||
|
undefined,
|
||||||
|
recover ? undefined : controller.signal,
|
||||||
|
);
|
||||||
|
await job?.onStart(recover);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (process.argv[2] === 'update') {
|
||||||
|
await stageUpgrade({
|
||||||
|
root,
|
||||||
|
tmp,
|
||||||
|
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,
|
||||||
|
stageUpgrade,
|
||||||
|
selectedUpgrade,
|
||||||
|
replaceAndReload,
|
||||||
|
reloadSystem,
|
||||||
|
startupHealthTimeout,
|
||||||
|
waitForHealth,
|
||||||
|
runProcess,
|
||||||
|
withLifecycleHooks,
|
||||||
|
main,
|
||||||
|
};
|
||||||
|
if (require.main === module)
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||||
|
|
||||||
|
function probeLimits(target) {
|
||||||
|
const small = target.arch === 'armv6';
|
||||||
|
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencyFailure(error) {
|
||||||
|
const text = `${error.message || ''}\n${error.stack || ''}\n${
|
||||||
|
error.stderr || ''
|
||||||
|
}`;
|
||||||
|
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
|
||||||
|
/node_modules[/\\]/.test(text)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
|
||||||
|
text,
|
||||||
|
)?.[1];
|
||||||
|
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
|
||||||
|
const inDependencies = (filename) =>
|
||||||
|
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
|
||||||
|
// Node reports a missing package entry as an absolute path and missing
|
||||||
|
// package-internal imports as relative paths. Neither is an app source error.
|
||||||
|
const dependencyMissing =
|
||||||
|
missing &&
|
||||||
|
((!missing.startsWith('.') &&
|
||||||
|
!path.isAbsolute(missing) &&
|
||||||
|
!missing.startsWith('node:')) ||
|
||||||
|
inDependencies(missing) ||
|
||||||
|
inDependencies(importer));
|
||||||
|
// Missing backend files, bad config and database errors must never trigger npm.
|
||||||
|
return (
|
||||||
|
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
|
||||||
|
Boolean(dependencyMissing)) ||
|
||||||
|
(/Could not locate the bindings file/.test(text) &&
|
||||||
|
/node_modules[/\\]/.test(text))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prestartBackend(
|
||||||
|
selected,
|
||||||
|
{ target, signal, limits = probeLimits(target) },
|
||||||
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const workspace = await fs.mkdtemp(
|
||||||
|
path.join(path.dirname(selected.source), 'prestart-'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const data = path.join(workspace, 'data');
|
||||||
|
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
|
||||||
|
await fs.mkdir(path.join(data, name), { recursive: true });
|
||||||
|
// Copy only backend code/metadata, never all frontend assets or node_modules.
|
||||||
|
await fs.mkdir(path.join(workspace, 'static'));
|
||||||
|
await fs.cp(
|
||||||
|
path.join(selected.static, 'build'),
|
||||||
|
path.join(workspace, 'static/build'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(selected.static, 'build-info.json'),
|
||||||
|
path.join(workspace, 'static/build-info.json'),
|
||||||
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(selected.source, 'version.yaml'),
|
||||||
|
path.join(workspace, 'version.yaml'),
|
||||||
|
);
|
||||||
|
await fs.symlink(
|
||||||
|
path.join(selected.source, 'node_modules'),
|
||||||
|
path.join(workspace, 'node_modules'),
|
||||||
|
'dir',
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
|
||||||
|
const entry = path.join(
|
||||||
|
workspace,
|
||||||
|
'static/build/bootstrap/upgradeProbe.js',
|
||||||
|
);
|
||||||
|
await fs.access(entry);
|
||||||
|
const expected = JSON.parse(
|
||||||
|
await fs.readFile(path.join(selected.static, 'build-info.json')),
|
||||||
|
);
|
||||||
|
const backendSha256 = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
|
||||||
|
.digest('hex');
|
||||||
|
const version = (
|
||||||
|
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
|
||||||
|
).match(
|
||||||
|
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
|
||||||
|
)?.[1];
|
||||||
|
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
|
||||||
|
// package-manager settings from escaping into the disposable backend.
|
||||||
|
const env = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
LANG: process.env.LANG || 'C.UTF-8',
|
||||||
|
HOME: workspace,
|
||||||
|
TMPDIR: workspace,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
QL_DIR: workspace,
|
||||||
|
QL_DATA_DIR: data,
|
||||||
|
QL_UPGRADE_PROBE: '1',
|
||||||
|
BACK_PORT: '0',
|
||||||
|
GRPC_PORT: '0',
|
||||||
|
BIND_HOST: '127.0.0.1',
|
||||||
|
BIND_HOST_GRPC: '127.0.0.1',
|
||||||
|
QlBaseUrl: '/',
|
||||||
|
NODE_PATH: '',
|
||||||
|
NODE_OPTIONS: '',
|
||||||
|
UV_THREADPOOL_SIZE: '1',
|
||||||
|
};
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let report,
|
||||||
|
failure,
|
||||||
|
output = '',
|
||||||
|
finished = false,
|
||||||
|
killTimer;
|
||||||
|
// No PM2/cluster workers. OpenSSL key generation is sequential in this
|
||||||
|
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
|
||||||
|
{
|
||||||
|
cwd: workspace,
|
||||||
|
env,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (child.pid) os.setPriority(child.pid, 10);
|
||||||
|
} catch {}
|
||||||
|
const kill = (signalName) => {
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32' && child.pid)
|
||||||
|
process.kill(-child.pid, signalName);
|
||||||
|
else child.kill(signalName);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ESRCH') failure ||= error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stop = (error) => {
|
||||||
|
failure ||= error;
|
||||||
|
kill('SIGTERM');
|
||||||
|
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||||
|
};
|
||||||
|
const abort = () =>
|
||||||
|
stop(signal.reason || new Error('Upgrade prestart aborted'));
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => stop(new Error('Upgrade prestart timed out')),
|
||||||
|
limits.timeoutMs,
|
||||||
|
);
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output = (output + chunk).slice(-32768);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
output = (output + chunk).slice(-32768);
|
||||||
|
});
|
||||||
|
child.on('message', (message) => {
|
||||||
|
if (message?.type === 'upgrade-probe') report = message;
|
||||||
|
});
|
||||||
|
const finish = (error) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(report);
|
||||||
|
};
|
||||||
|
child.on('error', (error) => finish(error));
|
||||||
|
// Descendants can retain our output pipes after their parent exits.
|
||||||
|
// Reap the group on exit rather than waiting for those pipes to close.
|
||||||
|
child.once('exit', () => kill('SIGKILL'));
|
||||||
|
child.on('close', (code, exitSignal) => {
|
||||||
|
// Reap descendants even when the backend exits unexpectedly.
|
||||||
|
kill('SIGKILL');
|
||||||
|
if (failure) return finish(failure);
|
||||||
|
if (code !== 0 || !report?.ok)
|
||||||
|
return finish(
|
||||||
|
Object.assign(
|
||||||
|
new Error(
|
||||||
|
report?.message ||
|
||||||
|
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
code: report?.code,
|
||||||
|
stack: report?.stack || output,
|
||||||
|
signal: exitSignal,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
report.build?.sourceCommit !== expected.sourceCommit ||
|
||||||
|
report.build?.backendSha256 !== backendSha256 ||
|
||||||
|
report.build?.version !== version
|
||||||
|
)
|
||||||
|
return finish(new Error('Upgrade prestart build identity mismatch'));
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairDependencies(source, { target, signal, env }) {
|
||||||
|
const workspace = await fs.mkdtemp(
|
||||||
|
path.join(path.dirname(source), 'repair-'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
await fs.readFile(path.join(source, 'package.json')),
|
||||||
|
);
|
||||||
|
if (pkg.packageManager !== 'pnpm@8.3.1')
|
||||||
|
throw new Error('Unsupported frozen-lockfile package manager');
|
||||||
|
// Install apart from the verified application: lifecycle scripts cannot
|
||||||
|
// rewrite source/static files or use the old installation's node_modules.
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, 'package.json'),
|
||||||
|
JSON.stringify({ ...pkg, scripts: {} }),
|
||||||
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(source, 'pnpm-lock.yaml'),
|
||||||
|
path.join(workspace, 'pnpm-lock.yaml'),
|
||||||
|
);
|
||||||
|
const small = target.arch === 'armv6';
|
||||||
|
const childEnv = {
|
||||||
|
PATH: env.PATH || process.env.PATH,
|
||||||
|
HOME: workspace,
|
||||||
|
TMPDIR: workspace,
|
||||||
|
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
|
||||||
|
small ? 256 : 384
|
||||||
|
}`,
|
||||||
|
NODE_PATH: '',
|
||||||
|
CI: 'true',
|
||||||
|
npm_config_jobs: '1',
|
||||||
|
MAKEFLAGS: '-j1',
|
||||||
|
...(small
|
||||||
|
? {
|
||||||
|
npm_config_build_from_source: 'true',
|
||||||
|
npm_config_force_process_config: 'true',
|
||||||
|
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||||
|
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
// Preserve network routing without inheriting Node/preload/compiler settings.
|
||||||
|
for (const name of [
|
||||||
|
'HTTP_PROXY',
|
||||||
|
'HTTPS_PROXY',
|
||||||
|
'ALL_PROXY',
|
||||||
|
'NO_PROXY',
|
||||||
|
'http_proxy',
|
||||||
|
'https_proxy',
|
||||||
|
'all_proxy',
|
||||||
|
'no_proxy',
|
||||||
|
])
|
||||||
|
if (typeof env[name] === 'string') childEnv[name] = env[name];
|
||||||
|
// Reuse matching installed headers on small devices; never inherit a
|
||||||
|
// caller's nodedir or accidentally compile against a different Node ABI.
|
||||||
|
const prefix = path.resolve(path.dirname(process.execPath), '..');
|
||||||
|
const header = await fs
|
||||||
|
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
|
||||||
|
.catch(() => '');
|
||||||
|
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
|
||||||
|
.map(
|
||||||
|
(part) =>
|
||||||
|
header.match(
|
||||||
|
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
|
||||||
|
)?.[1],
|
||||||
|
)
|
||||||
|
.join('.');
|
||||||
|
if (headerVersion === process.versions.node) {
|
||||||
|
childEnv.npm_config_nodedir = prefix;
|
||||||
|
childEnv.npm_config_force_process_config = 'true';
|
||||||
|
}
|
||||||
|
const run = (args) =>
|
||||||
|
runRepair('pnpm', args, {
|
||||||
|
cwd: workspace,
|
||||||
|
env: childEnv,
|
||||||
|
signal,
|
||||||
|
timeoutMs: small ? 1800000 : 600000,
|
||||||
|
});
|
||||||
|
const version = await run(['--version']);
|
||||||
|
if (version.trim() !== '8.3.1')
|
||||||
|
throw new Error('Dependency repair requires existing pnpm 8.3.1');
|
||||||
|
await run([
|
||||||
|
'install',
|
||||||
|
'--prod',
|
||||||
|
'--frozen-lockfile',
|
||||||
|
'--ignore-scripts',
|
||||||
|
'--child-concurrency=1',
|
||||||
|
'--network-concurrency=2',
|
||||||
|
]);
|
||||||
|
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
|
||||||
|
if (
|
||||||
|
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
|
||||||
|
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Dependency repair changed the frozen lockfile');
|
||||||
|
await fs.rm(path.join(source, 'node_modules'), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
await fs.rename(
|
||||||
|
path.join(workspace, 'node_modules'),
|
||||||
|
path.join(source, 'node_modules'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound output, wall time and the entire npm/node-gyp process group.
|
||||||
|
async function runRepair(program, args, options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
options.signal?.throwIfAborted();
|
||||||
|
let output = '',
|
||||||
|
stdout = '',
|
||||||
|
failure,
|
||||||
|
killTimer;
|
||||||
|
const child = spawn(program, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const kill = (sig) => {
|
||||||
|
try {
|
||||||
|
if (child.pid && process.platform !== 'win32')
|
||||||
|
process.kill(-child.pid, sig);
|
||||||
|
else child.kill(sig);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
const stop = (error) => {
|
||||||
|
failure ||= error;
|
||||||
|
kill('SIGTERM');
|
||||||
|
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||||
|
};
|
||||||
|
const abort = () =>
|
||||||
|
stop(options.signal.reason || new Error('Dependency repair aborted'));
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => stop(new Error('Dependency repair timed out')),
|
||||||
|
options.timeoutMs,
|
||||||
|
);
|
||||||
|
const capture = (chunk) => {
|
||||||
|
output = (output + chunk).slice(-32768);
|
||||||
|
};
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout = (stdout + chunk).slice(-32768);
|
||||||
|
capture(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', capture);
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
options.signal?.removeEventListener('abort', abort);
|
||||||
|
kill('SIGKILL');
|
||||||
|
};
|
||||||
|
child.on('error', (error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.once('exit', () => kill('SIGKILL'));
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
cleanup();
|
||||||
|
if (failure || code !== 0)
|
||||||
|
reject(
|
||||||
|
failure ||
|
||||||
|
new Error(
|
||||||
|
`Dependency repair failed (${signal || code}): ${output}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else resolve(stdout);
|
||||||
|
});
|
||||||
|
options.signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
if (options.signal?.aborted) abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
prestartBackend,
|
||||||
|
dependencyFailure,
|
||||||
|
repairDependencies,
|
||||||
|
probeLimits,
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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
|
// 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.
|
// 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');
|
const args = command.split('\0');
|
||||||
if (args.at(-1) === '') args.pop();
|
if (args.at(-1) === '') args.pop();
|
||||||
// Both the legacy fallback and the TS fallback launch one script without
|
// Accept only the two launch shapes we own. Never treat eval, preload or
|
||||||
// Node flags or application arguments. Reject other command shapes.
|
// application arguments containing this path as a running backend.
|
||||||
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
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;
|
continue;
|
||||||
const script = args[1]!;
|
const script = args[scriptIndex]!;
|
||||||
if (!script) continue;
|
if (!script) continue;
|
||||||
const absolute = path.isAbsolute(script)
|
const absolute = path.isAbsolute(script)
|
||||||
? script
|
? script
|
||||||
|
|||||||
@@ -214,8 +214,13 @@ async function reconcile(
|
|||||||
if (added || removed) {
|
if (added || removed) {
|
||||||
try {
|
try {
|
||||||
await api.call('system/notify', 'PUT', {
|
await api.call('system/notify', 'PUT', {
|
||||||
title: `${namespace} 订阅同步`,
|
title: translate(context.env, '%s 订阅同步', namespace),
|
||||||
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
|
content: translate(
|
||||||
|
context.env,
|
||||||
|
'新增 %s 个任务,删除 %s 个任务',
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
operationOutput(
|
operationOutput(
|
||||||
|
|||||||
@@ -53,23 +53,12 @@ export async function request(
|
|||||||
// Check HTTP status before decoding (proxies may return HTML on denial).
|
// Check HTTP status before decoding (proxies may return HTML on denial).
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
const suffix =
|
rejectResponse(
|
||||||
method !== 'GET' && response.status >= 500
|
response.status,
|
||||||
? translate(
|
'HTTP',
|
||||||
process.env,
|
method,
|
||||||
' 执行结果可能未知,请先检查%s状态再考虑重试。',
|
resource,
|
||||||
resource,
|
credentialsHint,
|
||||||
)
|
|
||||||
: '';
|
|
||||||
fail(
|
|
||||||
translate(
|
|
||||||
process.env,
|
|
||||||
'API 请求被拒绝(HTTP %s),请检查%s。%s',
|
|
||||||
response.status,
|
|
||||||
credentialsHint,
|
|
||||||
suffix,
|
|
||||||
),
|
|
||||||
[401, 403].includes(response.status) ? 3 : 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -100,28 +89,63 @@ export async function request(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (endpoint.split('?')[0] === 'user/login' && isRecord(result) && result.code === 420)
|
if (
|
||||||
fail(translate(process.env, '需要双因素验证,请调用 user two-factor-login。'), 3);
|
endpoint.split('?')[0] === 'user/login' &&
|
||||||
|
isRecord(result) &&
|
||||||
|
result.code === 420
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
translate(process.env, '需要双因素验证,请调用 user two-factor-login。'),
|
||||||
|
3,
|
||||||
|
);
|
||||||
if (!isRecord(result) || result.code !== 200) {
|
if (!isRecord(result) || result.code !== 200) {
|
||||||
const status =
|
const status =
|
||||||
isRecord(result) && typeof result.code === 'number'
|
isRecord(result) && typeof result.code === 'number'
|
||||||
? result.code
|
? result.code
|
||||||
: undefined;
|
: undefined;
|
||||||
fail(
|
rejectResponse(status, 'code', method, resource, credentialsHint);
|
||||||
translate(
|
|
||||||
process.env,
|
|
||||||
'API 请求被拒绝(code %s),请检查%s。',
|
|
||||||
status ?? translate(process.env, '未知'),
|
|
||||||
credentialsHint,
|
|
||||||
),
|
|
||||||
status === 401 || status === 403 ? 3 : 1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (options.output)
|
if (options.output)
|
||||||
fail(translate(process.env, '接口返回 JSON,未保存下载文件。'));
|
fail(translate(process.env, '接口返回 JSON,未保存下载文件。'));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectResponse(
|
||||||
|
status: number | undefined,
|
||||||
|
kind: 'HTTP' | 'code',
|
||||||
|
method: string,
|
||||||
|
resource: string,
|
||||||
|
credentialsHint: string,
|
||||||
|
): never {
|
||||||
|
const denied = status === 401 || status === 403;
|
||||||
|
const reason = denied
|
||||||
|
? translate(process.env, '请检查%s。', credentialsHint)
|
||||||
|
: status === 409
|
||||||
|
? translate(process.env, '资源冲突,请检查重复的名称或值。')
|
||||||
|
: status !== undefined && status >= 500
|
||||||
|
? translate(process.env, '服务端错误,请检查服务状态和日志。')
|
||||||
|
: translate(process.env, '请求未成功,请检查请求参数和服务日志。');
|
||||||
|
const suffix =
|
||||||
|
method !== 'GET' && (status === undefined || status >= 500)
|
||||||
|
? translate(
|
||||||
|
process.env,
|
||||||
|
' 执行结果可能未知,请先检查%s状态再考虑重试。',
|
||||||
|
resource,
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
fail(
|
||||||
|
translate(
|
||||||
|
process.env,
|
||||||
|
'API 请求失败(%s %s)。%s%s',
|
||||||
|
kind,
|
||||||
|
status ?? translate(process.env, '未知'),
|
||||||
|
reason,
|
||||||
|
suffix,
|
||||||
|
),
|
||||||
|
denied ? 3 : 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function authenticate(config: Credentials): Promise<Session> {
|
export async function authenticate(config: Credentials): Promise<Session> {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
client_id: config.clientId,
|
client_id: config.clientId,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export interface OpenOperation {
|
|||||||
anonymous?: boolean;
|
anonymous?: boolean;
|
||||||
}
|
}
|
||||||
export const openOperations: readonly OpenOperation[] = [
|
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-list', method: 'GET', path: 'crons/views' },
|
||||||
{
|
{
|
||||||
name: 'task view-create',
|
name: 'task view-create',
|
||||||
@@ -275,6 +281,18 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
|
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
|
||||||
{ name: 'script list', method: 'GET', path: 'scripts' },
|
{ name: 'script list', method: 'GET', path: 'scripts' },
|
||||||
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
|
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
|
||||||
|
{ name: 'script history-list', method: 'GET', path: 'scripts/history' },
|
||||||
|
{
|
||||||
|
name: 'script history-detail',
|
||||||
|
method: 'GET',
|
||||||
|
path: 'scripts/history/detail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'script history-restore',
|
||||||
|
method: 'PUT',
|
||||||
|
path: 'scripts/history/restore',
|
||||||
|
body: 'json',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'script create',
|
name: 'script create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -398,6 +416,7 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
body: 'json',
|
body: 'json',
|
||||||
},
|
},
|
||||||
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
|
{ 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 update', method: 'PUT', path: 'system/update' },
|
||||||
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
|
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
|
||||||
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
|
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
|
||||||
@@ -466,7 +485,12 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
path: 'system/config/dependence-clean',
|
path: 'system/config/dependence-clean',
|
||||||
body: 'json',
|
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 overview', method: 'GET', path: 'dashboard/overview' },
|
||||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||||
|
|||||||
@@ -17,20 +17,28 @@ export function configPath(): string {
|
|||||||
export function readConfig(): StoredConfig {
|
export function readConfig(): StoredConfig {
|
||||||
let fd: number | undefined;
|
let fd: number | undefined;
|
||||||
try {
|
try {
|
||||||
fd = fs.openSync(
|
const file = configPath();
|
||||||
configPath(),
|
const pathStat = fs.lstatSync(file);
|
||||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
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);
|
const stat = fs.fstatSync(fd);
|
||||||
if (
|
if (
|
||||||
!stat.isFile() ||
|
!stat.isFile() ||
|
||||||
stat.mode & 0o077 ||
|
pathStat.dev !== stat.dev ||
|
||||||
(process.getuid && stat.uid !== process.getuid())
|
pathStat.ino !== stat.ino
|
||||||
) {
|
) {
|
||||||
fail(
|
fail(
|
||||||
translate(
|
translate(
|
||||||
process.env,
|
process.env,
|
||||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
|
'CLI 配置必须是普通文件,且不能是符号链接。',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export async function openCommand(
|
|||||||
body,
|
body,
|
||||||
authenticated: !op.anonymous,
|
authenticated: !op.anonymous,
|
||||||
output: values.output as string | undefined,
|
output: values.output as string | undefined,
|
||||||
text: op.path === 'system/log',
|
text: ['system/log', 'system/config/node-mirror'].includes(op.path),
|
||||||
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
|
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
|
||||||
});
|
});
|
||||||
const data =
|
const data =
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
||||||
export const english: Record<string, string> = {
|
export const english: Record<string, string> = {
|
||||||
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
|
'%s 订阅同步': '%s subscription sync',
|
||||||
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
'新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks',
|
||||||
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
|
'需要双因素验证,请调用 user two-factor-login。':
|
||||||
|
'Two-factor authentication required; use user two-factor-login.',
|
||||||
|
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。':
|
||||||
|
'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
||||||
|
'接口返回 JSON,未保存下载文件。':
|
||||||
|
'The endpoint returned JSON; no download was saved.',
|
||||||
'已选择的 CLI 安装路径必须为绝对路径。':
|
'已选择的 CLI 安装路径必须为绝对路径。':
|
||||||
'The selected CLI installation path must be absolute.',
|
'The selected CLI installation path must be absolute.',
|
||||||
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
||||||
@@ -151,6 +156,15 @@ export const english: Record<string, string> = {
|
|||||||
应用凭据: 'application credentials',
|
应用凭据: 'application credentials',
|
||||||
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
||||||
未知: 'unknown',
|
未知: '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.',
|
||||||
|
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
|
||||||
|
|
||||||
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
||||||
' Execution outcome may be unknown; check %s status before retrying.',
|
' Execution outcome may be unknown; check %s status before retrying.',
|
||||||
'API 请求被拒绝(HTTP %s),请检查%s。%s':
|
'API 请求被拒绝(HTTP %s),请检查%s。%s':
|
||||||
@@ -161,10 +175,9 @@ export const english: Record<string, string> = {
|
|||||||
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
|
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
|
||||||
'API 请求被拒绝(code %s),请检查%s。':
|
'API 请求被拒绝(code %s),请检查%s。':
|
||||||
'API request rejected (code %s). Check %s.',
|
'API request rejected (code %s). Check %s.',
|
||||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
|
'CLI 配置必须是普通文件,且不能是符号链接。':
|
||||||
'CLI config must be an owned private regular file (0600).',
|
'CLI config must be a regular file, not a symbolic link.',
|
||||||
'CLI 配置无效,请运行 ql login。':
|
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
|
||||||
'Invalid CLI config. Run ql login.',
|
|
||||||
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
||||||
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
||||||
'Cannot read CLI config. Check file permissions and run ql login.',
|
'Cannot read CLI config. Check file permissions and run ql login.',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { execFileSync, spawnSync } = require('node:child_process');
|
|||||||
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
|
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
|
||||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||||
|
|
||||||
test('unmodified legacy Shell and TS agree on shell hook state and account modes', async (t) => {
|
test('Shell and CLI agree on isolated script state and account modes', async (t) => {
|
||||||
const root = await fs.realpath(
|
const root = await fs.realpath(
|
||||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
|
||||||
);
|
);
|
||||||
@@ -31,7 +31,7 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
|||||||
path.join(root, 'static/build'),
|
path.join(root, 'static/build'),
|
||||||
])
|
])
|
||||||
await fs.mkdir(directory, { recursive: true });
|
await fs.mkdir(directory, { recursive: true });
|
||||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
for (const name of ['task.sh', 'task-timeout.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||||
await fs.copyFile(
|
await fs.copyFile(
|
||||||
path.resolve(__dirname, '../../../shell', name),
|
path.resolve(__dirname, '../../../shell', name),
|
||||||
path.join(context.paths.dir_shell, name),
|
path.join(context.paths.dir_shell, name),
|
||||||
@@ -110,13 +110,13 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
|||||||
async () => {
|
async () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
|
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
|
||||||
{ name: 'exit', script: 'exit 7', code: 7, legacy: 7, after: false },
|
{ name: 'exit', script: 'exit 7', code: 7, legacy: 0, after: true },
|
||||||
{
|
{
|
||||||
name: 'errexit',
|
name: 'errexit',
|
||||||
script: 'set -e; false',
|
script: 'set -e; false',
|
||||||
code: 1,
|
code: 1,
|
||||||
legacy: 1,
|
legacy: 0,
|
||||||
after: false,
|
after: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'trap-return',
|
name: 'trap-return',
|
||||||
@@ -130,8 +130,8 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
|||||||
name: 'trap-exit',
|
name: 'trap-exit',
|
||||||
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
|
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
|
||||||
code: 7,
|
code: 7,
|
||||||
legacy: 7,
|
legacy: 0,
|
||||||
after: false,
|
after: true,
|
||||||
trap: true,
|
trap: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -174,8 +174,8 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
|||||||
assert.equal(result.exitCode, item.code, item.name);
|
assert.equal(result.exitCode, item.code, item.name);
|
||||||
const expected = [
|
const expected = [
|
||||||
'before',
|
'before',
|
||||||
...(item.after ? [`after:${item.code}`] : []),
|
|
||||||
...(item.trap ? ['exit-trap'] : []),
|
...(item.trap ? ['exit-trap'] : []),
|
||||||
|
...(item.after ? [`after:${item.code}`] : []),
|
||||||
];
|
];
|
||||||
for (const file of [legacyTrace, modernTrace])
|
for (const file of [legacyTrace, modernTrace])
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -355,6 +355,7 @@ t() { :; }
|
|||||||
sleep() { printf DELAYED; }
|
sleep() { printf DELAYED; }
|
||||||
enter_script_workdir() { :; }
|
enter_script_workdir() { :; }
|
||||||
run_else() { :; }
|
run_else() { :; }
|
||||||
|
run_task_command() { "$@"; }
|
||||||
which_program=true
|
which_program=true
|
||||||
main "$@"
|
main "$@"
|
||||||
`,
|
`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user