mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-05 11:09:57 +08:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f1b2d4bb | ||
|
|
5fdfd9077d | ||
|
|
ce2e396b3e | ||
|
|
49cec13fcd | ||
|
|
81de8f7094 | ||
|
|
dce4f60c47 | ||
|
|
1623e89a71 | ||
|
|
1a49e81ffd | ||
|
|
656b9419d6 | ||
|
|
8d2f67e263 | ||
|
|
2a32f528f1 | ||
|
|
384ce170c7 | ||
|
|
cc001ecebd | ||
|
|
04c8a63518 | ||
|
|
8976447b1e | ||
|
|
0a586fbd1d | ||
|
|
15d54866a8 | ||
|
|
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 | ||
|
|
bf63425805 | ||
|
|
a8310e7d51 | ||
|
|
a67d44cde5 | ||
|
|
6c487018c2 | ||
|
|
801a71d740 | ||
|
|
f051135fc4 | ||
|
|
2d6c2206b3 | ||
|
|
6acb665369 | ||
|
|
d9833c17d5 | ||
|
|
a60ec5587a | ||
|
|
4eb27427f8 | ||
|
|
041a437453 | ||
|
|
841740f19a | ||
|
|
a94e665054 | ||
|
|
d62d8f3025 | ||
|
|
4df52094f2 | ||
|
|
be2580d0e8 | ||
|
|
5f6049d80a | ||
|
|
0e975d1d6d | ||
|
|
134cd8337c | ||
|
|
88200f2805 | ||
|
|
decdab093f | ||
|
|
dfa73af104 | ||
|
|
f71fd5ebd6 | ||
|
|
afe9b5702b | ||
|
|
02e02491c4 | ||
|
|
276dfc2382 | ||
|
|
31f78e4d1f | ||
|
|
83e4490b57 | ||
|
|
2ac4f07fca | ||
|
|
6618240c61 | ||
|
|
0bb41ebe96 | ||
|
|
d67f33df39 | ||
|
|
24d539fc05 | ||
|
|
0af546cd1d | ||
|
|
0d33d2321a | ||
|
|
79964f149c | ||
|
|
bcb2471768 | ||
|
|
fcf0ee619b | ||
|
|
f3ec352066 | ||
|
|
023d0f1cc4 | ||
|
|
f526d3f972 | ||
|
|
e28f746294 |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitnexus
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.env*
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
audit
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
src/.umi*
|
||||||
|
**/__pycache__
|
||||||
|
**/*.log
|
||||||
@@ -6,6 +6,13 @@ BACK_PORT=5700
|
|||||||
# BIND_HOST=0.0.0.0
|
# BIND_HOST=0.0.0.0
|
||||||
# BIND_HOST_GRPC=0.0.0.0
|
# BIND_HOST_GRPC=0.0.0.0
|
||||||
|
|
||||||
|
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
|
||||||
|
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
|
||||||
|
# QL_TRUST_PROXY=1
|
||||||
|
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
|
||||||
|
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
|
||||||
|
# QL_TRUST_PROXY=loopback
|
||||||
|
|
||||||
LOG_LEVEL='info'
|
LOG_LEVEL='info'
|
||||||
|
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
@@ -14,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,20 +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:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
|
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
|
||||||
@@ -32,37 +68,9 @@ 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
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -72,6 +80,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
node-version-file: ".nvmrc"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -80,82 +89,165 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build:front
|
pnpm build:front
|
||||||
pnpm build:back
|
pnpm build:back
|
||||||
|
pnpm build:info
|
||||||
|
|
||||||
|
- name: Pack the npm panel archive from the verified build
|
||||||
|
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-static-${{ github.sha }}
|
||||||
|
path: static/
|
||||||
|
if-no-files-found: error
|
||||||
|
include-hidden-files: true
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: copy to static repo
|
- 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
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: qinglong-static-${{ github.sha }}
|
||||||
|
path: static/
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -205,10 +297,11 @@ 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: |
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
@@ -216,6 +309,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
||||||
@@ -224,17 +319,24 @@ 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
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: qinglong-static-${{ github.sha }}
|
||||||
|
path: static/
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -283,10 +385,11 @@ 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: |
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
@@ -295,6 +398,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian
|
file: ./docker/Dockerfile.debian
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||||
@@ -309,11 +414,16 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: qinglong-static-${{ github.sha }}
|
||||||
|
path: static/
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -351,7 +461,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
@@ -359,6 +469,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.310
|
file: ./docker/Dockerfile.310
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: |
|
tags: |
|
||||||
whyour/qinglong:python3.10
|
whyour/qinglong:python3.10
|
||||||
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||||
@@ -374,11 +486,16 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: qinglong-static-${{ github.sha }}
|
||||||
|
path: static/
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -416,7 +533,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
MAINTAINER=${{ github.repository_owner }}
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
QL_BRANCH=${{ github.ref_name }}
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
@@ -425,33 +542,212 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian310
|
file: ./docker/Dockerfile.debian310
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: |
|
tags: |
|
||||||
whyour/qinglong:debian-python3.10
|
whyour/qinglong:debian-python3.10
|
||||||
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
||||||
cache-from: type=registry,ref=whyour/qinglong:cache-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
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||||
|
|
||||||
publish:
|
# Unmodified amd64 packages for glibc and musl migration acceptance. This does not
|
||||||
if: ${{ github.ref_name == 'master' }}
|
# publish bundles or advance development/formal channel indexes.
|
||||||
needs: [build-alpine, build-debian]
|
build-npm-migration-bundle:
|
||||||
|
needs: [build-debian, build-alpine]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
libc: [glibc, musl]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v3
|
- uses: docker/setup-buildx-action@v4
|
||||||
with:
|
- name: Export the official platform package from this run's image digest
|
||||||
version: "8.3.1"
|
env:
|
||||||
|
IMAGE_DIGEST: ${{ matrix.libc == 'musl' && needs.build-alpine.outputs.image-digest || needs.build-debian.outputs.image-digest }}
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: build front and back
|
|
||||||
run: |
|
run: |
|
||||||
pnpm install --frozen-lockfile
|
set -euo pipefail
|
||||||
pnpm build:front
|
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||||
pnpm build:back
|
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
|
||||||
|
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: npm-migration-bundle-${{ matrix.libc }}-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
- name: publish npm package
|
verify-npm-package:
|
||||||
|
needs: [build-static, build-npm-migration-bundle, build-alpine]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node: ['20.18.1', '22.17.0', '24', 'alpine']
|
||||||
|
container:
|
||||||
|
image: ${{ matrix.node == 'alpine' && format('ghcr.io/{0}@{1}', github.repository, needs.build-alpine.outputs.image-digest) || format('node:{0}-bookworm', matrix.node) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: npm-migration-bundle-${{ matrix.node == 'alpine' && 'musl' || 'glibc' }}-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||||
|
- name: Install isolated service test tools
|
||||||
run: |
|
run: |
|
||||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
if [ -f /etc/alpine-release ]; then
|
||||||
npm publish
|
apk add --no-cache python3 openssl curl jq nginx procps make g++
|
||||||
|
else
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||||
|
fi
|
||||||
|
# The published legacy launcher resets PATH to standard directories.
|
||||||
|
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||||
|
# remains private to each verification run.
|
||||||
|
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||||
|
- name: Install and upgrade the exact npm archive
|
||||||
|
if: matrix.node != '20.18.1'
|
||||||
|
env:
|
||||||
|
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
|
||||||
|
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
|
||||||
|
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
|
||||||
|
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
|
||||||
|
- name: Reject an unsupported runtime while preserving the old installation
|
||||||
|
if: matrix.node == '20.18.1'
|
||||||
|
run: node scripts/verify-panel-runtime-floor.cjs "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel-results/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||||
|
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Set up Node for trusted publishing
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: npm-panel/
|
||||||
|
|
||||||
|
- name: Publish npm package with OIDC
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
archives=(npm-panel/*.tgz)
|
||||||
|
if [ "${#archives[@]}" -ne 1 ]; then
|
||||||
|
echo '::error::Expected exactly one verified panel npm archive.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
archive="${archives[0]}"
|
||||||
|
metadata=$(tar -xOf "$archive" package/package.json)
|
||||||
|
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
|
||||||
|
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||||
|
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||||
|
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||||
|
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||||
|
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||||
|
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||||
|
else
|
||||||
|
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-upgrade-bundles:
|
||||||
|
# Both channels publish packages from this run's immutable image digests.
|
||||||
|
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||||
|
needs: [build-alpine, build-debian]
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { platform: linux/amd64, libc: musl, name: x64-musl }
|
||||||
|
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
|
||||||
|
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
|
||||||
|
- { platform: linux/arm64, libc: musl, name: arm64-musl }
|
||||||
|
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
|
||||||
|
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||||
|
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||||
|
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||||
|
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||||
|
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||||
|
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||||
|
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
- name: Build and probe the immutable platform package
|
||||||
|
env:
|
||||||
|
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||||
|
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||||
|
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||||
|
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
digest="$ALPINE_DIGEST"
|
||||||
|
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||||
|
test -n "$digest"
|
||||||
|
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||||
|
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
|
||||||
|
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --output type=local,dest=output/upgrade .
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||||
|
path: output/upgrade/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish-upgrade-bundles:
|
||||||
|
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||||
|
needs: build-upgrade-bundles
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
concurrency:
|
||||||
|
group: upgrade-channel-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- run: pnpm install --prod --frozen-lockfile
|
||||||
|
- run: npm --prefix cli ci
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: upgrade-*-${{ github.sha }}
|
||||||
|
path: output/upgrade
|
||||||
|
merge-multiple: true
|
||||||
|
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||||
|
- name: Publish complete packages before advancing the channel
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
name: CLI package
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'cli/**'
|
||||||
|
- 'shell/**'
|
||||||
|
- 'back/api/**'
|
||||||
|
- 'back/loaders/deps.ts'
|
||||||
|
- 'package.json'
|
||||||
|
- '.github/workflows/cli-package.yml'
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
paths:
|
||||||
|
- 'cli/**'
|
||||||
|
- 'shell/**'
|
||||||
|
- 'back/api/**'
|
||||||
|
- 'back/loaders/deps.ts'
|
||||||
|
- 'package.json'
|
||||||
|
- '.github/workflows/cli-package.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: Publish the verified CLI package (master stable / develop prerelease)
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
prerelease_version:
|
||||||
|
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Keep validation runs separate from releases; never interrupt a running release.
|
||||||
|
# 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:
|
||||||
|
package:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node: ['22.12.0', '24']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: cli/package-lock.json
|
||||||
|
- name: Install test interpreters and tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
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
|
||||||
|
- 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 run check:cli
|
||||||
|
- run: npm run test:cli
|
||||||
|
- name: Verify offline package installation
|
||||||
|
env:
|
||||||
|
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
|
||||||
|
run: node cli/scripts/verify-package.cjs
|
||||||
|
- name: Upload verified npm archive
|
||||||
|
if: matrix.node == '24'
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-cli-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/cli-package/*.tgz
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package
|
||||||
|
if: >-
|
||||||
|
github.repository == 'whyour/qinglong' &&
|
||||||
|
((github.ref == 'refs/heads/master' &&
|
||||||
|
(github.event_name == 'push' ||
|
||||||
|
(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
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
package-manager-cache: false
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: qinglong-cli-${{ github.sha }}
|
||||||
|
path: cli-package
|
||||||
|
- name: Publish verified npm archive
|
||||||
|
env:
|
||||||
|
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
archives=(cli-package/*.tgz)
|
||||||
|
if [ "${#archives[@]}" -ne 1 ]; then
|
||||||
|
echo '::error::Expected exactly one verified CLI archive.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
archive="${archives[0]}"
|
||||||
|
metadata=$(tar -xOf "$archive" package/package.json)
|
||||||
|
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$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
|
||||||
|
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."
|
||||||
|
exit 0
|
||||||
|
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
||||||
|
echo '::error::Could not check the published CLI version; refusing to publish.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
name: QingLong 3.0 Approval Management Kubernetes live evidence
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-approval-management-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approval-management-live:
|
|
||||||
name: Three-node Approval, OIDC, mTLS and CloudNativePG
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
|
||||||
minimum_kib="$((25 * 1024 * 1024))"
|
|
||||||
if (( available_kib < minimum_kib )); then
|
|
||||||
echo "Approval live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.32.8
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
|
||||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
|
||||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
|
||||||
|
|
||||||
- name: Recheck static Approval and evidence contracts
|
|
||||||
run: |
|
|
||||||
pnpm audit:cluster-deployment:ql3
|
|
||||||
node --test \
|
|
||||||
test/back/ql3ApprovalManagementKubernetesLiveContract.test.cjs \
|
|
||||||
test/back/ql3ApprovalManagementKubernetesLiveAudit.test.cjs
|
|
||||||
pnpm --filter @qinglong/runtime-core build
|
|
||||||
|
|
||||||
- name: Preload the digest-bound K3s distribution
|
|
||||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
|
||||||
|
|
||||||
- name: Prove Approval over three nodes, mTLS, OIDC and CloudNativePG
|
|
||||||
env:
|
|
||||||
QL3_APPROVAL_MANAGEMENT_KUBERNETES_LIVE: '1'
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
|
||||||
QL3_APPROVAL_REPORT: ${{ runner.temp }}/ql3-approval/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
mkdir -m 0700 "$(dirname "${QL3_APPROVAL_REPORT}")"
|
|
||||||
node scripts/ql3-approval-management-kubernetes-live-contract.cjs \
|
|
||||||
"--report=${QL3_APPROVAL_REPORT}"
|
|
||||||
|
|
||||||
- name: Re-audit private report and isolated cleanup
|
|
||||||
env:
|
|
||||||
QL3_APPROVAL_REPORT: ${{ runner.temp }}/ql3-approval/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(stat -c '%a' "${QL3_APPROVAL_REPORT}")" = '600'
|
|
||||||
pnpm audit:approval-management-kubernetes-live:ql3 \
|
|
||||||
"--report=${QL3_APPROVAL_REPORT}"
|
|
||||||
sha256sum "${QL3_APPROVAL_REPORT}"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-approval-live-)"
|
|
||||||
test -z "$(docker network ls -q --filter name=ql3-approval-live-)"
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
diff --unified \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Upload audited content-free Approval evidence
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-approval-management-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-approval/report.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
|||||||
name: QingLong 3.0 CloudNativePG DR live evidence
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-cloudnativepg-dr-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cloudnativepg-dr-live:
|
|
||||||
name: Barman WAL, latest and PITR recovery
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 120
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Require enough ephemeral disk for four isolated K3s nodes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
|
||||||
minimum_kib="$((35 * 1024 * 1024))"
|
|
||||||
if (( available_kib < minimum_kib )); then
|
|
||||||
echo "CloudNativePG DR live gate requires at least 35 GiB free; found ${available_kib} KiB" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.32.8
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Fetch checksum-locked operator manifests
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/cert-manager.yaml" \
|
|
||||||
https://github.com/cert-manager/cert-manager/releases/download/v1.20.3/cert-manager.yaml
|
|
||||||
echo "7ee74ba06845213e96d8ceaff3d20dd51e682765c1418eddda4e8780ba082261 ${RUNNER_TEMP}/cert-manager.yaml" | sha256sum --check
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/barman-cloud.yaml" \
|
|
||||||
https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.13.0/manifest.yaml
|
|
||||||
echo "d2e71e7b06822448f1a421f05781846cfdb9cc621e7ef32eef5e20c5133213b0 ${RUNNER_TEMP}/barman-cloud.yaml" | sha256sum --check
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
|
||||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
|
||||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
|
||||||
|
|
||||||
- name: Recheck the static backup and supply-chain contracts
|
|
||||||
run: |
|
|
||||||
pnpm audit:cloudnativepg-backup:ql3
|
|
||||||
pnpm audit:barman-cloud-supply-chain:ql3
|
|
||||||
pnpm audit:cert-manager-selection:ql3
|
|
||||||
node --test \
|
|
||||||
test/back/ql3CloudNativePgBarmanLiveContract.test.cjs \
|
|
||||||
test/back/ql3CloudNativePgDrEvidenceAudit.test.cjs \
|
|
||||||
test/back/ql3CloudNativePgBarmanWorkflow.test.cjs
|
|
||||||
|
|
||||||
- name: Prove TLS object storage, continuous WAL, latest restore and PITR
|
|
||||||
env:
|
|
||||||
QL3_CLOUDNATIVEPG_BARMAN_LIVE: '1'
|
|
||||||
QL3_SOURCE_REVISION: ${{ github.sha }}
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_CERT_MANAGER_MANIFEST_FILE: ${{ runner.temp }}/cert-manager.yaml
|
|
||||||
QL3_BARMAN_MANIFEST_FILE: ${{ runner.temp }}/barman-cloud.yaml
|
|
||||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
|
||||||
QL3_DR_REPORT: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
mkdir -m 0700 "$(dirname "${QL3_DR_REPORT}")"
|
|
||||||
pnpm test:cloudnativepg-barman-live:ql3 -- \
|
|
||||||
"--report=${QL3_DR_REPORT}"
|
|
||||||
|
|
||||||
- name: Re-audit the exact private report and isolated cleanup
|
|
||||||
env:
|
|
||||||
QL3_DR_REPORT: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(stat -c '%a' "${QL3_DR_REPORT}")" = '600'
|
|
||||||
pnpm audit:cloudnativepg-dr-evidence:ql3 -- \
|
|
||||||
"--report=${QL3_DR_REPORT}"
|
|
||||||
sha256sum "${QL3_DR_REPORT}"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-barman-dr-)"
|
|
||||||
test -z "$(docker network ls -q --filter name=ql3-barman-dr-)"
|
|
||||||
test -z "$(docker ps -aq --filter label=io.qinglong.ql3.live=cloudnativepg-barman-disaster-recovery)"
|
|
||||||
test -z "$(docker network ls -q --filter label=io.qinglong.ql3.live=cloudnativepg-barman-disaster-recovery)"
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
diff --unified \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Upload the audited content-free DR evidence
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-cloudnativepg-dr-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
|||||||
name: QingLong 3.0 Kubernetes deployment live contract
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- next
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- next
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-kubernetes-deployment-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
kubernetes-deployment-live:
|
|
||||||
name: Deployment Head and fenced resource retirement
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 25
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.34.3
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Preload the digest-reviewed K3s distribution
|
|
||||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
|
||||||
|
|
||||||
- name: Recheck the closed deployment ceremony contract
|
|
||||||
run: node --test test/back/ql3KubernetesDeploymentCeremony.test.cjs
|
|
||||||
|
|
||||||
- name: Prove install, UID/resourceVersion retirement and Head convergence
|
|
||||||
env:
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_DEPLOYMENT_LIVE_REPORT: ${{ runner.temp }}/ql3-kubernetes-deployment/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
install -d -m 0700 "$(dirname "${QL3_DEPLOYMENT_LIVE_REPORT}")"
|
|
||||||
node scripts/ql3-kubernetes-deployment-live-contract.cjs > \
|
|
||||||
"${QL3_DEPLOYMENT_LIVE_REPORT}"
|
|
||||||
node -e \
|
|
||||||
"const fs=require('node:fs');const report=JSON.parse(fs.readFileSync(process.env.QL3_DEPLOYMENT_LIVE_REPORT,'utf8'));if(report.cleanupComplete!==true||report.receiptAuditCompatible!==true||report.retirement?.receiptAuditCompatible!==true||report.retirement?.targetAbsent!==true||report.retirement?.uidResourceVersionDeletePreconditions!==true||report.retirement?.deploymentHeadCas!==true||report.retirement?.unixSocketProxy!==true)process.exit(1)"
|
|
||||||
|
|
||||||
- name: Verify isolated cleanup
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-deploy-live-)"
|
|
||||||
test -z "$(docker network ls -q --filter name=ql3-deploy-live-)"
|
|
||||||
|
|
||||||
- name: Upload content-free deployment evidence
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-kubernetes-deployment-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-kubernetes-deployment/report.json
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
name: QingLong 3.0 Run Management Kubernetes live evidence
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-run-management-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-management-live:
|
|
||||||
name: Three-node Run retry, stop, OIDC, mTLS and CloudNativePG
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
|
||||||
minimum_kib="$((25 * 1024 * 1024))"
|
|
||||||
if (( available_kib < minimum_kib )); then
|
|
||||||
echo "Run management live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.32.8
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
|
||||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
|
||||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
|
||||||
|
|
||||||
- name: Recheck static Run management and evidence contracts
|
|
||||||
run: |
|
|
||||||
pnpm audit:cluster-deployment:ql3
|
|
||||||
node --test \
|
|
||||||
test/back/ql3RunManagementDeployment.test.cjs \
|
|
||||||
test/back/ql3RunManagementKubernetesLiveContract.test.cjs \
|
|
||||||
test/back/ql3RunManagementKubernetesLiveAudit.test.cjs
|
|
||||||
pnpm --filter @qinglong/cluster-admin build
|
|
||||||
pnpm --filter @qinglong/cluster-control build
|
|
||||||
|
|
||||||
- name: Preload the digest-bound K3s distribution
|
|
||||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
|
||||||
|
|
||||||
- name: Prove Run retry and stop over three nodes, mTLS, OIDC and CloudNativePG
|
|
||||||
env:
|
|
||||||
QL3_RUN_MANAGEMENT_KUBERNETES_LIVE: '1'
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
|
||||||
QL3_RUN_MANAGEMENT_REPORT: ${{ runner.temp }}/ql3-run-management/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
mkdir -m 0700 "$(dirname "${QL3_RUN_MANAGEMENT_REPORT}")"
|
|
||||||
node scripts/ql3-run-management-kubernetes-live-contract.cjs \
|
|
||||||
"--report=${QL3_RUN_MANAGEMENT_REPORT}"
|
|
||||||
|
|
||||||
- name: Re-audit private report and isolated cleanup
|
|
||||||
env:
|
|
||||||
QL3_RUN_MANAGEMENT_REPORT: ${{ runner.temp }}/ql3-run-management/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(stat -c '%a' "${QL3_RUN_MANAGEMENT_REPORT}")" = '600'
|
|
||||||
pnpm audit:run-management-kubernetes-live:ql3 \
|
|
||||||
"--report=${QL3_RUN_MANAGEMENT_REPORT}"
|
|
||||||
sha256sum "${QL3_RUN_MANAGEMENT_REPORT}"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-run-live-)"
|
|
||||||
test -z "$(docker network ls -q --filter name=ql3-run-live-)"
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
diff --unified \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Upload audited content-free Run management evidence
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-run-management-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-run-management/report.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
name: QingLong 3.0 Security Administration Kubernetes live evidence
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- next
|
|
||||||
paths:
|
|
||||||
- .github/workflows/ql3-security-administration-live.yml
|
|
||||||
- deploy/containers/ql3-cluster-admin/**
|
|
||||||
- deploy/containers/ql3-cluster-control/**
|
|
||||||
- deploy/kubernetes/ql3-cluster/base/**
|
|
||||||
- deploy/kubernetes/ql3-cluster/operations/cloudnative-pg/**
|
|
||||||
- deploy/kubernetes/ql3-cluster/operations/security-administration/**
|
|
||||||
- package.json
|
|
||||||
- packages/ql3-cluster-admin/**
|
|
||||||
- packages/ql3-cluster-control/**
|
|
||||||
- packages/ql3-cluster-postgres/**
|
|
||||||
- packages/ql3-runtime-core/**
|
|
||||||
- pnpm-lock.yaml
|
|
||||||
- scripts/lib/ql3-k3s-docker-live.cjs
|
|
||||||
- scripts/ql3-security-administration-kubernetes-*.cjs
|
|
||||||
- test/back/ql3SecurityAdministrationKubernetes*.test.cjs
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-security-administration-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security-administration-live:
|
|
||||||
name: Three-node Security Administration, PostgreSQL and PVC custody
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
|
||||||
minimum_kib="$((25 * 1024 * 1024))"
|
|
||||||
if (( available_kib < minimum_kib )); then
|
|
||||||
echo "Security Administration live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.34.3
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
|
||||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
|
||||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
|
||||||
|
|
||||||
- name: Recheck static Security Administration and evidence contracts
|
|
||||||
run: |
|
|
||||||
pnpm --filter @qinglong/runtime-core build
|
|
||||||
pnpm --filter @qinglong/cluster-postgres build
|
|
||||||
pnpm audit:security-administration-kubernetes:ql3
|
|
||||||
node --test \
|
|
||||||
test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs \
|
|
||||||
test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs
|
|
||||||
|
|
||||||
- name: Preload the digest-bound K3s distribution
|
|
||||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
|
||||||
|
|
||||||
- name: Prepare Linux bridge netfilter for nested K3s
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
sudo modprobe br_netfilter
|
|
||||||
test -e /proc/sys/net/bridge/bridge-nf-call-iptables
|
|
||||||
sudo sysctl -w net.ipv4.ip_forward=1
|
|
||||||
sudo sysctl -w net.bridge.bridge-nf-call-iptables=1
|
|
||||||
|
|
||||||
- name: Prove Security Administration through K3s, PostgreSQL and PVC
|
|
||||||
env:
|
|
||||||
QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE: '1'
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
|
||||||
QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
mkdir -m 0700 "$(dirname "${QL3_SECURITY_ADMINISTRATION_REPORT}")"
|
|
||||||
pnpm test:security-administration-kubernetes-live:ql3 \
|
|
||||||
"--report=${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
|
||||||
|
|
||||||
- name: Re-audit content-free report and isolated cleanup
|
|
||||||
env:
|
|
||||||
QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(stat -c '%a' "${QL3_SECURITY_ADMINISTRATION_REPORT}")" = '600'
|
|
||||||
pnpm audit:security-administration-kubernetes-live:ql3 \
|
|
||||||
"--report=${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
|
||||||
sha256sum "${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-security-live-)"
|
|
||||||
test -z "$(docker network ls -q --filter name=ql3-security-live-)"
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
diff --unified \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Upload audited content-free Security Administration evidence
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-security-administration-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-security-administration/report.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
name: QingLong 3.0 Worker Kubernetes rollout live evidence
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ql3-worker-kubernetes-rollout-live-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
worker-kubernetes-rollout-live:
|
|
||||||
name: Production Worker, Recreate, mTLS, PVC and Session drain
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
version: '8.3.1'
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24.18.0'
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Require isolated fixture capacity
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
|
||||||
minimum_kib="$((15 * 1024 * 1024))"
|
|
||||||
if (( available_kib < minimum_kib )); then
|
|
||||||
echo "Worker rollout live gate requires at least 15 GiB free; found ${available_kib} KiB" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Install workspace dependencies without lifecycle scripts
|
|
||||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
- name: Install verified kubectl v1.34.3
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
|
||||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
|
||||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
|
||||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
|
||||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
|
||||||
|
|
||||||
- name: Recheck Worker runtime and evidence contracts
|
|
||||||
run: |
|
|
||||||
pnpm --filter @qinglong/worker-runtime test
|
|
||||||
node --test test/back/ql3WorkerKubernetesRolloutLiveAudit.test.cjs
|
|
||||||
pnpm audit:worker-deployment:ql3
|
|
||||||
|
|
||||||
- name: Preload digest-bound fixture images
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker pull \
|
|
||||||
rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c
|
|
||||||
docker tag \
|
|
||||||
rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c \
|
|
||||||
rancher/k3s:v1.34.3-k3s1
|
|
||||||
docker pull \
|
|
||||||
postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296
|
|
||||||
docker tag \
|
|
||||||
postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296 \
|
|
||||||
postgres:18.4-bookworm
|
|
||||||
|
|
||||||
- name: Prove production Worker Kubernetes lifecycle
|
|
||||||
env:
|
|
||||||
QL3_WORKER_KUBERNETES_ROLLOUT_LIVE: '1'
|
|
||||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
|
||||||
QL3_WORKER_KUBERNETES_REPORT: ${{ runner.temp }}/ql3-worker/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
mkdir -m 0700 "$(dirname "${QL3_WORKER_KUBERNETES_REPORT}")"
|
|
||||||
pnpm test:worker-kubernetes-rollout-live:ql3 \
|
|
||||||
"--report=${QL3_WORKER_KUBERNETES_REPORT}"
|
|
||||||
|
|
||||||
- name: Re-audit private report and isolated cleanup
|
|
||||||
env:
|
|
||||||
QL3_WORKER_KUBERNETES_REPORT: ${{ runner.temp }}/ql3-worker/report.json
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(stat -c '%a' "${QL3_WORKER_KUBERNETES_REPORT}")" = '600'
|
|
||||||
pnpm audit:worker-kubernetes-rollout-live:ql3 \
|
|
||||||
"--report=${QL3_WORKER_KUBERNETES_REPORT}"
|
|
||||||
sha256sum "${QL3_WORKER_KUBERNETES_REPORT}"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-worker-rollout-live-)"
|
|
||||||
test -z "$(docker ps -aq --filter name=ql3-worker-rollout-postgres-live-)"
|
|
||||||
docker volume ls --quiet --filter dangling=true | sort > \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
diff --unified \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
|
||||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
- name: Upload audited content-free Worker evidence
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ql3-worker-kubernetes-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: ${{ runner.temp }}/ql3-worker/report.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
compression-level: 9
|
|
||||||
overwrite: false
|
|
||||||
include-hidden-files: false
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
name: Release readiness
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/release-readiness.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
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: '22'
|
||||||
|
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
|
||||||
|
|
||||||
|
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/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
- image: alpine-python3.10
|
||||||
|
file: docker/Dockerfile.310
|
||||||
|
cache: alpine-python3.10
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
- image: debian-python3.10
|
||||||
|
file: docker/Dockerfile.debian310
|
||||||
|
cache: debian-python3.10
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
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"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); assert.equal(typeof require("@mikro-orm/sql").NodeSqliteDialect,"function"); const db=new (require("node:sqlite").DatabaseSync)(":memory:"); assert.equal(db.prepare("SELECT 42 AS value").get().value,42); db.close(); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||||
|
- name: Export native Debian image for isolated dev validation
|
||||||
|
if: matrix.image == 'debian'
|
||||||
|
run: |
|
||||||
|
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||||
|
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||||
|
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: matrix.image == 'debian'
|
||||||
|
with:
|
||||||
|
name: release-readiness-debian-image
|
||||||
|
path: release-readiness-debian-image.*
|
||||||
|
compression-level: 0
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
name: SQLite migration candidate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sqlite-migration-candidate-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-static:
|
||||||
|
if: ${{ (github.event_name == 'pull_request' && github.head_ref == 'codex/sqlite-runtime-migration' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/sqlite-runtime-migration') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.17.0'
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: cp .env.example .env
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build:front && npm run build:back && npm run build:info
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-static-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: static/
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
test-node24:
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: cp .env.example .env
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
build-images:
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- libc: musl
|
||||||
|
file: docker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
- libc: glibc
|
||||||
|
file: docker/Dockerfile.debian
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-static-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: static/
|
||||||
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
- uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
id: image
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
platforms: ${{ matrix.platforms }}
|
||||||
|
build-args: |
|
||||||
|
QL_BRANCH=codex/sqlite-runtime-migration
|
||||||
|
SOURCE_COMMIT=${{ env.SOURCE_COMMIT }}
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository }}:migration-candidate-${{ env.SOURCE_COMMIT }}-${{ matrix.libc }}
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.libc == 'musl' && 'alpine' || 'debian' }}
|
||||||
|
- name: Record the immutable candidate image
|
||||||
|
env:
|
||||||
|
IMAGE_DIGEST: ${{ steps.image.outputs.digest }}
|
||||||
|
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||||
|
run: |
|
||||||
|
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||||
|
printf '%s\n' "ghcr.io/$GITHUB_REPOSITORY@$IMAGE_DIGEST" > "$BUNDLE_LIBC-image.txt"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: ${{ matrix.libc }}-image.txt
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-bundles:
|
||||||
|
needs: build-images
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { platform: linux/amd64, libc: musl, name: x64-musl }
|
||||||
|
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
|
||||||
|
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
|
||||||
|
- { platform: linux/arm64, libc: musl, name: arm64-musl }
|
||||||
|
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
|
||||||
|
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||||
|
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||||
|
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||||
|
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||||
|
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||||
|
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||||
|
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
- name: Build and verify both online hops using the unchanged legacy client
|
||||||
|
env:
|
||||||
|
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||||
|
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
image=$(cat "$BUNDLE_LIBC-image.txt")
|
||||||
|
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
|
||||||
|
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --output type=local,dest=output/upgrade .
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-bundles-${{ matrix.name }}-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: output/upgrade/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
verify-index:
|
||||||
|
needs: [build-bundles, test-node24]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ env.SOURCE_COMMIT }}
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- run: pnpm install --prod --frozen-lockfile && npm --prefix cli ci
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
pattern: migration-bundles-*-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: output/upgrade/
|
||||||
|
merge-multiple: true
|
||||||
|
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||||
|
- name: Validate every platform without publishing a channel
|
||||||
|
run: |
|
||||||
|
node -e "const fs=require('fs'),{releaseIndex}=require('./scripts/publish-upgrade-bundles.cjs'); const index=releaseIndex('output/upgrade',process.env.SOURCE_COMMIT,process.env.GITHUB_REPOSITORY,{migration:true}); fs.writeFileSync('output/upgrade/candidate-index.json',JSON.stringify(index));"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: migration-candidate-index-${{ env.SOURCE_COMMIT }}
|
||||||
|
path: |
|
||||||
|
output/upgrade/candidate-index.json
|
||||||
|
output/upgrade/install-bundle.cjs
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regression:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: '8.3.1'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: cp .env.example .env
|
||||||
|
- run: pnpm test
|
||||||
|
- run: pnpm build:back
|
||||||
|
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: execution-baseline
|
||||||
|
path: execution-baseline.json
|
||||||
+13
-3
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/packages/*/node_modules
|
|
||||||
/packages/*/dist
|
|
||||||
/npm-debug.log*
|
/npm-debug.log*
|
||||||
/yarn-error.log
|
/yarn-error.log
|
||||||
/yarn.lock
|
/yarn.lock
|
||||||
@@ -11,7 +9,6 @@
|
|||||||
|
|
||||||
# production
|
# production
|
||||||
/static
|
/static
|
||||||
/.ql3-panel-dist
|
|
||||||
/data
|
/data
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
@@ -32,9 +29,22 @@ __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/
|
||||||
|
|
||||||
# local Kubernetes overlays
|
# local Kubernetes overlays
|
||||||
/deploy/kubernetes/overlays/local/
|
/deploy/kubernetes/overlays/local/
|
||||||
|
|
||||||
|
# Local security audit artifacts
|
||||||
|
/audit/
|
||||||
|
|
||||||
|
# Local browser tests, screenshots, reports, and temporary test output
|
||||||
|
/.playwright-cli/
|
||||||
|
/output/
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
/blob-report/
|
||||||
|
/coverage/
|
||||||
|
/.nyc_output/
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
/.tmp/
|
|
||||||
/.github/
|
|
||||||
/.vscode/
|
|
||||||
/.history/
|
|
||||||
/back/**/*.ts
|
|
||||||
/back/**/*.json
|
|
||||||
/cli/
|
|
||||||
/data/
|
|
||||||
/src/
|
|
||||||
/static/**/*.js.map
|
|
||||||
/static/**/*.gz
|
|
||||||
/.editorconfig
|
|
||||||
/.gitignore
|
|
||||||
/.prettierignore
|
|
||||||
/.prettierrc
|
|
||||||
/.umirc.ts
|
|
||||||
/nodemon.json
|
|
||||||
/pnpm-lock.yaml
|
|
||||||
/tsconfig.back.json
|
|
||||||
/tsconfig.json
|
|
||||||
/typings.d.ts
|
|
||||||
/.env
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
strict-peer-dependencies=false
|
strict-peer-dependencies=false
|
||||||
resolve-peers-from-workspace-root=false
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Node.js 原生部署与 Python venv
|
||||||
|
|
||||||
|
面板要求 Node.js >=22.17,推荐 Node.js 24,并要求该 Node 构建提供 `node:sqlite`。应用升级保留现有 Node;旧 Node 20 环境需先升级运行时。远程管理 CLI 的最低版本要求独立于面板。
|
||||||
|
|
||||||
|
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
|
||||||
|
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
|
||||||
|
|
||||||
|
- `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 中重新安装。
|
||||||
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
- Support system level notification
|
- Support system level notification
|
||||||
- Support dark mode
|
- Support dark mode
|
||||||
- Support cell phone operation
|
- Support cell phone operation
|
||||||
|
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
|
||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
@@ -79,6 +80,29 @@ npm i @whyour/qinglong
|
|||||||
|
|
||||||
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
|
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||||
|
|
||||||
|
## Remote CLI and Agent Skill
|
||||||
|
|
||||||
|
Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents.
|
||||||
|
|
||||||
|
Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @whyour/qinglong-cli
|
||||||
|
ql login --url https://ql.example.com
|
||||||
|
ql auth status --json
|
||||||
|
ql task list --json
|
||||||
|
ql subscription list --json
|
||||||
|
ql --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help.
|
||||||
|
|
||||||
|
The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md).
|
||||||
|
|
||||||
|
[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md)
|
||||||
|
|
||||||
|
The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
|||||||
- 支持系统级通知
|
- 支持系统级通知
|
||||||
- 支持暗黑模式
|
- 支持暗黑模式
|
||||||
- 支持手机端操作
|
- 支持手机端操作
|
||||||
|
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
|
||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
@@ -81,6 +82,29 @@ npm i @whyour/qinglong
|
|||||||
|
|
||||||
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
|
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||||
|
|
||||||
|
## 远程 CLI 与 Agent Skill
|
||||||
|
|
||||||
|
使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。
|
||||||
|
|
||||||
|
需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @whyour/qinglong-cli
|
||||||
|
ql login --url https://ql.example.com
|
||||||
|
ql auth status --json
|
||||||
|
ql task list --json
|
||||||
|
ql subscription list --json
|
||||||
|
ql --help
|
||||||
|
```
|
||||||
|
|
||||||
|
在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。
|
||||||
|
|
||||||
|
npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。
|
||||||
|
|
||||||
|
[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md)
|
||||||
|
|
||||||
|
包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
import {
|
||||||
|
diagnoseClientIp,
|
||||||
|
getTrustProxyConfig,
|
||||||
|
updateTrustProxy,
|
||||||
|
} from '../shared/trustProxy';
|
||||||
|
|
||||||
|
const route = Router();
|
||||||
|
|
||||||
|
export default (app: Router) => {
|
||||||
|
app.use('/system/client-ip', route);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/config',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.send({ code: 200, data: await getTrustProxyConfig() });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
trustProxy: Joi.string().max(500).required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await updateTrustProxy(req.body.trustProxy);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
res.send({
|
||||||
|
code: 400,
|
||||||
|
message: error instanceof Error ? error.message : t('配置更新失败'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/diagnose',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.send({ code: 200, data: await diagnoseClientIp(req) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+14
-24
@@ -4,11 +4,11 @@ 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';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
@@ -72,20 +72,15 @@ 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 resolvedPath = join(basePath, cleanName);
|
config.configPath,
|
||||||
const normalized = join(resolvedPath);
|
[name],
|
||||||
// Verify the resolved path stays within allowed directory
|
config.blackFileList,
|
||||||
if (!normalized.startsWith(basePath)) {
|
);
|
||||||
return res.send({ code: 403, message: t('文件路径无效') });
|
if (!normalized) {
|
||||||
}
|
|
||||||
// Check blacklist on actual filename (not user input)
|
|
||||||
if (config.blackFileList.includes(basename(normalized))) {
|
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
await writeFileWithLock(normalized, content);
|
await writeFileWithLock(normalized, content);
|
||||||
@@ -96,15 +91,10 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get('/:file', (req: Request, res: Response) => {
|
||||||
'/:file',
|
return res.send({
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
code: 410,
|
||||||
try {
|
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||||
const configService = Container.get(ConfigService);
|
});
|
||||||
await configService.getFile(req.params.file, res);
|
});
|
||||||
} catch (e) {
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
+46
-13
@@ -7,12 +7,35 @@ import { celebrate, Joi } from 'celebrate';
|
|||||||
import { commonCronSchema } from '../validation/schedule';
|
import { commonCronSchema } from '../validation/schedule';
|
||||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
|
import cronClient from '../schedule/client';
|
||||||
|
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/crons', route);
|
app.use('/crons', route);
|
||||||
|
|
||||||
|
route.use(async (req, res, next) => {
|
||||||
|
// Keep stop/status callbacks available even when the scheduler is down.
|
||||||
|
if (
|
||||||
|
['POST', 'PUT', 'DELETE'].includes(req.method) &&
|
||||||
|
[
|
||||||
|
'/',
|
||||||
|
'/run',
|
||||||
|
'/enable',
|
||||||
|
'/disable',
|
||||||
|
'/views/enable',
|
||||||
|
'/views/disable',
|
||||||
|
].includes(req.path)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await cronClient.readiness.ensureReady();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/views',
|
'/views',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -304,16 +327,34 @@ export default (app: Router) => {
|
|||||||
params: Joi.object({
|
params: Joi.object({
|
||||||
id: Joi.number().required(),
|
id: Joi.number().required(),
|
||||||
}),
|
}),
|
||||||
|
query: Joi.object({
|
||||||
|
offset: Joi.number().integer().min(0).optional(),
|
||||||
|
limit: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(1024 * 1024)
|
||||||
|
.optional(),
|
||||||
|
tail: Joi.boolean().optional(),
|
||||||
|
t: Joi.string().optional(),
|
||||||
|
}).unknown(true),
|
||||||
}),
|
}),
|
||||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const cronService = Container.get(CronService);
|
const cronService = Container.get(CronService);
|
||||||
const result = await cronService.log(req.params.id);
|
const result = await cronService.log(req.params.id, {
|
||||||
|
offset: req.query.offset as unknown as number,
|
||||||
|
limit: req.query.limit as unknown as number,
|
||||||
|
tail: req.query.tail as unknown as boolean,
|
||||||
|
});
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 200,
|
code: 200,
|
||||||
data: result.content,
|
data: result.content,
|
||||||
logStatus: result.status,
|
logStatus: result.status,
|
||||||
|
offset: result.offset,
|
||||||
|
nextOffset: result.nextOffset,
|
||||||
|
total: result.total,
|
||||||
|
truncated: result.truncated,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
@@ -436,11 +477,6 @@ export default (app: Router) => {
|
|||||||
last_running_time: Joi.number().optional().allow(null),
|
last_running_time: Joi.number().optional().allow(null),
|
||||||
last_execution_time: Joi.number().optional().allow(null),
|
last_execution_time: Joi.number().optional().allow(null),
|
||||||
exit_code: Joi.number().optional().allow(null),
|
exit_code: Joi.number().optional().allow(null),
|
||||||
execution_id: Joi.string()
|
|
||||||
.pattern(
|
|
||||||
/^legacy-system:[1-9][0-9]{0,12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -467,13 +503,10 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const instances = await RunningInstanceModel.findAll({
|
const instances = await RunningInstanceModel.find(
|
||||||
where: {
|
{ cron_id: req.params.id },
|
||||||
cron_id: req.params.id,
|
{ orderBy: [{ started_at: 'desc' }] },
|
||||||
},
|
);
|
||||||
order: [['started_at', 'DESC']],
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
return res.send({ code: 200, data: instances });
|
return res.send({ code: 200, data: instances });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
|
|||||||
+119
-143
@@ -1,38 +1,32 @@
|
|||||||
|
import { statistics } from '../data/statistics';
|
||||||
|
import { Query, QuerySpec, orderBy } from '../data/queries';
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { fn, col, where, Op } from 'sequelize';
|
|
||||||
import { CrontabModel } from '../data/cron';
|
import { CrontabModel } from '../data/cron';
|
||||||
import { CrontabStatModel } from '../data/cronStats';
|
import { CrontabStatModel } from '../data/cronStats';
|
||||||
import {
|
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||||
RunningInstanceModel,
|
|
||||||
InstanceStatus,
|
|
||||||
} from '../data/runningInstance';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { t, tf } from '../shared/i18n';
|
import { t, tf } from '../shared/i18n';
|
||||||
|
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/dashboard', route);
|
app.use('/dashboard', route);
|
||||||
|
|
||||||
route.post('/record', async (req: Request, res: Response) => {
|
route.post('/record', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { ref_id, code, elapsed } = req.body;
|
const { ref_id, code, elapsed } = req.body;
|
||||||
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
|
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
|
||||||
|
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
const isSuccess = code === 0 ? 1 : 0;
|
const isSuccess = code === 0 ? 1 : 0;
|
||||||
const isFail = code !== 0 ? 1 : 0;
|
const isFail = code !== 0 ? 1 : 0;
|
||||||
const elapsedMs = (Number(elapsed) || 0) * 1000;
|
const elapsedMs = (Number(elapsed) || 0) * 1000;
|
||||||
|
const existing = await CrontabStatModel.findOne(
|
||||||
const existing = await CrontabStatModel.findOne({
|
{ ref_id: Number(ref_id), date: today },
|
||||||
where: { ref_id: Number(ref_id), date: today },
|
{},
|
||||||
});
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await CrontabStatModel.update(
|
await CrontabStatModel.nativeUpdate(
|
||||||
|
{ id: existing.id },
|
||||||
{
|
{
|
||||||
run_count: (existing.run_count || 0) + 1,
|
run_count: (existing.run_count || 0) + 1,
|
||||||
success_count: (existing.success_count || 0) + isSuccess,
|
success_count: (existing.success_count || 0) + isSuccess,
|
||||||
@@ -40,10 +34,9 @@ export default (app: Router) => {
|
|||||||
total_time: (existing.total_time || 0) + elapsedMs,
|
total_time: (existing.total_time || 0) + elapsedMs,
|
||||||
max_time: Math.max(existing.max_time || 0, elapsedMs),
|
max_time: Math.max(existing.max_time || 0, elapsedMs),
|
||||||
},
|
},
|
||||||
{ where: { id: existing.id } },
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await CrontabStatModel.create({
|
await CrontabStatModel.insert({
|
||||||
ref_id: Number(ref_id),
|
ref_id: Number(ref_id),
|
||||||
date: today,
|
date: today,
|
||||||
run_count: 1,
|
run_count: 1,
|
||||||
@@ -53,41 +46,27 @@ export default (app: Router) => {
|
|||||||
max_time: elapsedMs,
|
max_time: elapsedMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send({ code: 200 });
|
res.send({ code: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.send({ code: 500 });
|
res.send({ code: 500 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/overview',
|
'/overview',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
const [total, enabled, disabled, stats] = await Promise.all([
|
const [total, enabled, disabled, stats] = await Promise.all([
|
||||||
CrontabModel.count(),
|
CrontabModel.count({}),
|
||||||
CrontabModel.count({ where: { isDisabled: 0 } }),
|
CrontabModel.count({ isDisabled: 0 }),
|
||||||
CrontabModel.count({ where: { isDisabled: 1 } }),
|
CrontabModel.count({ isDisabled: 1 }),
|
||||||
CrontabStatModel.findOne({
|
statistics.totals(today),
|
||||||
attributes: [
|
|
||||||
[fn('SUM', col('run_count')), 'total_runs'],
|
|
||||||
[fn('SUM', col('success_count')), 'total_success'],
|
|
||||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
|
||||||
[fn('SUM', col('total_time')), 'total_time'],
|
|
||||||
],
|
|
||||||
where: { date: today },
|
|
||||||
raw: true,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const row = stats as any;
|
const row = stats as any;
|
||||||
const totalRuns = Number(row?.total_runs) || 0;
|
const totalRuns = Number(row?.total_runs) || 0;
|
||||||
const totalSuccess = Number(row?.total_success) || 0;
|
const totalSuccess = Number(row?.total_success) || 0;
|
||||||
const totalFail = Number(row?.total_fail) || 0;
|
const totalFail = Number(row?.total_fail) || 0;
|
||||||
const totalTime = Number(row?.total_time) || 0;
|
const totalTime = Number(row?.total_time) || 0;
|
||||||
|
|
||||||
res.send({
|
res.send({
|
||||||
code: 200,
|
code: 200,
|
||||||
data: {
|
data: {
|
||||||
@@ -97,7 +76,10 @@ export default (app: Router) => {
|
|||||||
todayRuns: totalRuns,
|
todayRuns: totalRuns,
|
||||||
todaySuccess: totalSuccess,
|
todaySuccess: totalSuccess,
|
||||||
todayFail: totalFail,
|
todayFail: totalFail,
|
||||||
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
|
successRate:
|
||||||
|
totalRuns > 0
|
||||||
|
? ((totalSuccess / totalRuns) * 100).toFixed(1)
|
||||||
|
: '0',
|
||||||
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
|
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -106,7 +88,48 @@ export default (app: Router) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
for (const isSuccess of [false, true]) {
|
||||||
|
route.get(
|
||||||
|
isSuccess ? '/successes' : '/failures',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const countColumn = isSuccess ? 'success_count' : 'fail_count';
|
||||||
|
const rows = (await statistics.results(
|
||||||
|
dayjs().format('YYYY-MM-DD'),
|
||||||
|
countColumn,
|
||||||
|
)) as any[];
|
||||||
|
const crons =
|
||||||
|
rows.length > 0
|
||||||
|
? await CrontabModel.find(
|
||||||
|
{ id: { ['$in']: rows.map((row) => Number(row.ref_id)) } },
|
||||||
|
{
|
||||||
|
fields: ['id', 'name', 'command'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
|
||||||
|
res.send({
|
||||||
|
code: 200,
|
||||||
|
data: rows.map((row) => {
|
||||||
|
const id = Number(row.ref_id);
|
||||||
|
const cron = cronMap.get(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: cron?.name || cron?.command || tf('任务#%s', id),
|
||||||
|
command: cron?.command || '',
|
||||||
|
[isSuccess ? 'successCount' : 'failCount']: Number(
|
||||||
|
row.result_count,
|
||||||
|
),
|
||||||
|
deleted: !cron,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
route.get(
|
route.get(
|
||||||
'/trend',
|
'/trend',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -116,22 +139,7 @@ export default (app: Router) => {
|
|||||||
for (let i = days - 1; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
|
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
|
||||||
}
|
}
|
||||||
|
const rows = (await statistics.trend(dates)) as any[];
|
||||||
const rows = (await CrontabStatModel.findAll({
|
|
||||||
attributes: [
|
|
||||||
'date',
|
|
||||||
[fn('SUM', col('run_count')), 'total_runs'],
|
|
||||||
[fn('SUM', col('success_count')), 'total_success'],
|
|
||||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
|
||||||
],
|
|
||||||
where: {
|
|
||||||
date: { [Op.in]: dates },
|
|
||||||
},
|
|
||||||
group: ['date'],
|
|
||||||
order: [['date', 'ASC']],
|
|
||||||
raw: true,
|
|
||||||
})) as any[];
|
|
||||||
|
|
||||||
const dataMap: Record<string, any> = {};
|
const dataMap: Record<string, any> = {};
|
||||||
rows.forEach((r: any) => {
|
rows.forEach((r: any) => {
|
||||||
dataMap[r.date] = {
|
dataMap[r.date] = {
|
||||||
@@ -140,89 +148,52 @@ export default (app: Router) => {
|
|||||||
fail: Number(r.total_fail) || 0,
|
fail: Number(r.total_fail) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = dates.map((d) => ({
|
const data = dates.map((d) => ({
|
||||||
date: dayjs(d).format('MM-DD'),
|
date: dayjs(d).format('MM-DD'),
|
||||||
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
|
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.send({ code: 200, data });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/top-time',
|
'/top-time',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const rows = (await statistics.ranking(today, 'total_time')) as any[];
|
||||||
const rows = (await CrontabStatModel.findAll({
|
|
||||||
attributes: [
|
|
||||||
'ref_id',
|
|
||||||
[fn('SUM', col('total_time')), 'total_time'],
|
|
||||||
[fn('SUM', col('run_count')), 'run_count'],
|
|
||||||
[fn('MAX', col('max_time')), 'max_time'],
|
|
||||||
],
|
|
||||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
|
||||||
group: ['ref_id'],
|
|
||||||
order: [[fn('SUM', col('total_time')), 'DESC']],
|
|
||||||
limit: 5,
|
|
||||||
raw: true,
|
|
||||||
})) as any[];
|
|
||||||
|
|
||||||
const ids = rows.map((r) => Number(r.ref_id));
|
const ids = rows.map((r) => Number(r.ref_id));
|
||||||
const crons = await CrontabModel.findAll({
|
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
|
||||||
where: { id: { [Op.in]: ids } },
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
const nameMap: Record<number, string> = {};
|
const nameMap: Record<number, string> = {};
|
||||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
crons.forEach((c: any) => {
|
||||||
|
nameMap[c.id] = c.name || c.command;
|
||||||
|
});
|
||||||
const data = rows.map((r: any, i) => ({
|
const data = rows.map((r: any, i) => ({
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||||
maxTime: Number(r.max_time),
|
maxTime: Number(r.max_time),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.send({ code: 200, data });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/top-count',
|
'/top-count',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const rows = (await statistics.ranking(today, 'run_count')) as any[];
|
||||||
const rows = (await CrontabStatModel.findAll({
|
|
||||||
attributes: [
|
|
||||||
'ref_id',
|
|
||||||
[fn('SUM', col('run_count')), 'run_count'],
|
|
||||||
[fn('SUM', col('total_time')), 'total_time'],
|
|
||||||
[fn('SUM', col('success_count')), 'success_count'],
|
|
||||||
],
|
|
||||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
|
||||||
group: ['ref_id'],
|
|
||||||
order: [[fn('SUM', col('run_count')), 'DESC']],
|
|
||||||
limit: 5,
|
|
||||||
raw: true,
|
|
||||||
})) as any[];
|
|
||||||
|
|
||||||
const ids = rows.map((r) => Number(r.ref_id));
|
const ids = rows.map((r) => Number(r.ref_id));
|
||||||
const crons = await CrontabModel.findAll({
|
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
|
||||||
where: { id: { [Op.in]: ids } },
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
const nameMap: Record<number, string> = {};
|
const nameMap: Record<number, string> = {};
|
||||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
crons.forEach((c: any) => {
|
||||||
|
nameMap[c.id] = c.name || c.command;
|
||||||
|
});
|
||||||
const data = rows.map((r: any, i) => ({
|
const data = rows.map((r: any, i) => ({
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||||
@@ -230,48 +201,42 @@ export default (app: Router) => {
|
|||||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||||
successRate:
|
successRate:
|
||||||
Number(r.run_count) > 0
|
Number(r.run_count) > 0
|
||||||
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
|
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(
|
||||||
|
1,
|
||||||
|
)
|
||||||
: '0',
|
: '0',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.send({ code: 200, data });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/runtime',
|
'/runtime',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const runningInstances = await RunningInstanceModel.findAll({
|
const runningInstances = await RunningInstanceModel.find(
|
||||||
where: {
|
{
|
||||||
status: InstanceStatus.running,
|
status: InstanceStatus.running,
|
||||||
},
|
},
|
||||||
raw: true,
|
{},
|
||||||
});
|
);
|
||||||
|
const queuedCrons = await CrontabModel.find(
|
||||||
const queuedCrons = await CrontabModel.findAll({
|
{
|
||||||
where: {
|
|
||||||
status: 3, // queued
|
status: 3, // queued
|
||||||
},
|
},
|
||||||
raw: true,
|
{},
|
||||||
});
|
);
|
||||||
|
|
||||||
// Fetch cron names for running instances
|
// Fetch cron names for running instances
|
||||||
const cronIds = [
|
const cronIds = [
|
||||||
...new Set(runningInstances.map((i: any) => i.cron_id)),
|
...new Set(runningInstances.map((i: any) => i.cron_id)),
|
||||||
];
|
];
|
||||||
const crons =
|
const crons =
|
||||||
cronIds.length > 0
|
cronIds.length > 0
|
||||||
? await CrontabModel.findAll({
|
? await CrontabModel.find({ id: cronIds }, {})
|
||||||
where: { id: cronIds },
|
|
||||||
raw: true,
|
|
||||||
})
|
|
||||||
: [];
|
: [];
|
||||||
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
|
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
|
||||||
|
|
||||||
const now = dayjs().unix();
|
const now = dayjs().unix();
|
||||||
const running = runningInstances.map((inst: any) => {
|
const running = runningInstances.map((inst: any) => {
|
||||||
const cron = cronMap.get(inst.cron_id);
|
const cron = cronMap.get(inst.cron_id);
|
||||||
@@ -284,19 +249,18 @@ export default (app: Router) => {
|
|||||||
logPath: inst.log_path,
|
logPath: inst.log_path,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayAgo = dayjs().subtract(24, 'hour').unix();
|
const dayAgo = dayjs().subtract(24, 'hour').unix();
|
||||||
const idleTasks = await CrontabModel.findAll({
|
const idleTasks = await CrontabModel.find(
|
||||||
where: {
|
{
|
||||||
isDisabled: 0,
|
isDisabled: 0,
|
||||||
status: 1,
|
status: 1,
|
||||||
last_execution_time: { [Op.lt]: dayAgo },
|
last_execution_time: { ['$lt']: dayAgo },
|
||||||
},
|
},
|
||||||
order: [['last_execution_time', 'ASC']],
|
{
|
||||||
limit: 5,
|
orderBy: orderBy([['last_execution_time', 'ASC']]),
|
||||||
raw: true,
|
limit: 5,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
res.send({
|
res.send({
|
||||||
code: 200,
|
code: 200,
|
||||||
data: {
|
data: {
|
||||||
@@ -317,33 +281,45 @@ export default (app: Router) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/labels',
|
'/labels',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
const [crons, stats] = (await Promise.all([
|
const [crons, stats] = await Promise.all([
|
||||||
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
|
CrontabModel.find({ isDisabled: 0 }, {}),
|
||||||
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
|
CrontabStatModel.find({ date: today }, {}),
|
||||||
]));
|
]);
|
||||||
|
|
||||||
const statMap: Record<number, any> = {};
|
const statMap: Record<number, any> = {};
|
||||||
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
|
stats.forEach((s: any) => {
|
||||||
|
statMap[s.ref_id] = s;
|
||||||
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
|
});
|
||||||
|
const labelMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
count: number;
|
||||||
|
runs: number;
|
||||||
|
success: number;
|
||||||
|
totalTime: number;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
crons.forEach((c) => {
|
crons.forEach((c) => {
|
||||||
let rawLabels = c.labels;
|
let rawLabels = c.labels;
|
||||||
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
|
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
|
||||||
const labels: string[] = Array.isArray(rawLabels)
|
const labels: string[] = Array.isArray(rawLabels)
|
||||||
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
|
? [
|
||||||
|
...new Set(
|
||||||
|
(rawLabels as string[]).filter((l: string) => !isEmpty(l)),
|
||||||
|
),
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
if (labels.length === 0) {
|
if (labels.length === 0) {
|
||||||
labels.push(t('未分类'));
|
labels.push(t('未分类'));
|
||||||
}
|
}
|
||||||
const st = statMap[c.id!];
|
const st = statMap[c.id!];
|
||||||
labels.forEach((label: string) => {
|
labels.forEach((label: string) => {
|
||||||
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
if (!labelMap[label])
|
||||||
|
labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
||||||
labelMap[label].count += 1;
|
labelMap[label].count += 1;
|
||||||
if (st) {
|
if (st) {
|
||||||
labelMap[label].runs += Number(st.run_count) || 0;
|
labelMap[label].runs += Number(st.run_count) || 0;
|
||||||
@@ -352,24 +328,22 @@ export default (app: Router) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = Object.entries(labelMap)
|
const data = Object.entries(labelMap)
|
||||||
.map(([label, v]) => ({
|
.map(([label, v]) => ({
|
||||||
label,
|
label,
|
||||||
count: v.count,
|
count: v.count,
|
||||||
todayRuns: v.runs,
|
todayRuns: v.runs,
|
||||||
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
successRate:
|
||||||
|
v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
||||||
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
|
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.todayRuns - a.todayRuns);
|
.sort((a, b) => b.todayRuns - a.todayRuns);
|
||||||
|
|
||||||
res.send({ code: 200, data });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/system',
|
'/system',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -382,7 +356,9 @@ export default (app: Router) => {
|
|||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
memTotal: os.totalmem(),
|
memTotal: os.totalmem(),
|
||||||
memFree: os.freemem(),
|
memFree: os.freemem(),
|
||||||
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
|
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(
|
||||||
|
1,
|
||||||
|
),
|
||||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||||
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
|
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const healthService = Container.get(HealthService);
|
const healthService = Container.get(HealthService);
|
||||||
const health = await healthService.check();
|
const health = await healthService.check();
|
||||||
res.status(200).send({
|
res.status(health.status === 'ok' ? 200 : 503).send({
|
||||||
code: 200,
|
code: health.status === 'ok' ? 200 : 503,
|
||||||
data: health,
|
data: health,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import subscription from './subscription';
|
|||||||
import update from './update';
|
import update from './update';
|
||||||
import dashboard from './dashboard';
|
import dashboard from './dashboard';
|
||||||
import health from './health';
|
import health from './health';
|
||||||
|
import clientIp from './clientIp';
|
||||||
|
import retention from './retention';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const app = Router();
|
const app = Router();
|
||||||
@@ -28,6 +30,8 @@ export default () => {
|
|||||||
update(app);
|
update(app);
|
||||||
dashboard(app);
|
dashboard(app);
|
||||||
health(app);
|
health(app);
|
||||||
|
clientIp(app);
|
||||||
|
retention(app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
};
|
};
|
||||||
|
|||||||
+30
-21
@@ -5,13 +5,13 @@ import { Logger } from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import {
|
import {
|
||||||
getFileContentByName,
|
|
||||||
readDirs,
|
readDirs,
|
||||||
removeAnsi,
|
removeAnsi,
|
||||||
rmPath,
|
rmPath,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import LogService from '../services/log';
|
import LogService from '../services/log';
|
||||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||||
|
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
const blacklist = ['.tmp'];
|
const blacklist = ['.tmp'];
|
||||||
|
|
||||||
@@ -34,6 +34,20 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/detail',
|
'/detail',
|
||||||
|
celebrate({
|
||||||
|
query: Joi.object({
|
||||||
|
path: Joi.string().allow('').optional(),
|
||||||
|
file: Joi.string().required(),
|
||||||
|
offset: Joi.number().integer().min(0).optional(),
|
||||||
|
limit: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(MAX_LOG_CHUNK_BYTES)
|
||||||
|
.optional(),
|
||||||
|
tail: Joi.boolean().optional(),
|
||||||
|
t: Joi.string().optional(),
|
||||||
|
}).unknown(true),
|
||||||
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const logService = Container.get(LogService);
|
const logService = Container.get(LogService);
|
||||||
@@ -52,11 +66,19 @@ export default (app: Router) => {
|
|||||||
where: { log_path: logPath, status: InstanceStatus.running },
|
where: { log_path: logPath, status: InstanceStatus.running },
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = await getFileContentByName(finalPath);
|
const chunk = await readLogChunk(finalPath, {
|
||||||
|
offset: req.query.offset as unknown as number,
|
||||||
|
limit: req.query.limit as unknown as number,
|
||||||
|
tail: req.query.tail as unknown as boolean,
|
||||||
|
});
|
||||||
res.send({
|
res.send({
|
||||||
code: 200,
|
code: 200,
|
||||||
data: removeAnsi(content),
|
data: removeAnsi(chunk.content),
|
||||||
logStatus: runningInstance ? 'running' : undefined,
|
logStatus: runningInstance ? 'running' : undefined,
|
||||||
|
offset: chunk.offset,
|
||||||
|
nextOffset: chunk.nextOffset,
|
||||||
|
total: chunk.total,
|
||||||
|
truncated: chunk.truncated,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
@@ -66,24 +88,11 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/:file',
|
'/:file',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response) => {
|
||||||
try {
|
return res.send({
|
||||||
const logService = Container.get(LogService);
|
code: 410,
|
||||||
const finalPath = logService.checkFilePath(
|
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||||
(req.query.path as string) || '',
|
});
|
||||||
(req.params.file as string) || '',
|
|
||||||
);
|
|
||||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
|
||||||
return res.send({
|
|
||||||
code: 403,
|
|
||||||
message: t('暂无权限'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const content = await getFileContentByName(finalPath);
|
|
||||||
res.send({ code: 200, data: content });
|
|
||||||
} catch (e) {
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import RetentionService from '../services/retention';
|
||||||
|
import { MAX_RETENTION_DAYS } from '../shared/retention';
|
||||||
|
|
||||||
|
const route = Router();
|
||||||
|
const policySchema = {
|
||||||
|
runningInstanceRetentionDays: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(MAX_RETENTION_DAYS)
|
||||||
|
.required(),
|
||||||
|
cronStatRetentionDays: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(MAX_RETENTION_DAYS)
|
||||||
|
.required(),
|
||||||
|
};
|
||||||
|
const cleanupSchema = {
|
||||||
|
...policySchema,
|
||||||
|
dependenceCacheTypes: Joi.array()
|
||||||
|
.items(Joi.string().valid('node', 'python3'))
|
||||||
|
.unique()
|
||||||
|
.default([]),
|
||||||
|
compactDatabase: Joi.boolean().default(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (app: Router) => {
|
||||||
|
app.use('/system/storage-retention', route);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/config',
|
||||||
|
celebrate({ body: Joi.object(policySchema) }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const service = Container.get(RetentionService);
|
||||||
|
const data = await service.updatePolicy(req.body);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/preview',
|
||||||
|
celebrate({ body: Joi.object(cleanupSchema) }),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const service = Container.get(RetentionService);
|
||||||
|
const data = await service.preview(req.body);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/cleanup',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
...cleanupSchema,
|
||||||
|
confirmation: Joi.string().valid('CLEAN').required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const service = Container.get(RetentionService);
|
||||||
|
const data = await service.cleanup(req.body);
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+216
-40
@@ -1,37 +1,74 @@
|
|||||||
import {
|
import { randomUUID } from 'crypto';
|
||||||
fileExist,
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
isPathInside,
|
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||||
readDirs,
|
|
||||||
readDir,
|
|
||||||
rmPath,
|
|
||||||
IFile,
|
|
||||||
} from '../config/util';
|
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Logger } from 'winston';
|
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((rootPath) =>
|
return config.writePathList.some((x) =>
|
||||||
isPathInside(rootPath, resolved),
|
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.scriptPath);
|
cb(null, config.tmpPath);
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
filename: function (req, file, cb) {
|
||||||
cb(null, file.originalname);
|
cb(null, randomUUID());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({ storage: storage });
|
||||||
@@ -59,6 +96,11 @@ export default (app: Router) => {
|
|||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
];
|
];
|
||||||
if (req.query.path) {
|
if (req.query.path) {
|
||||||
|
if (
|
||||||
|
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||||
|
) {
|
||||||
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
|
}
|
||||||
result = await readDir(
|
result = await readDir(
|
||||||
req.query.path as string,
|
req.query.path as string,
|
||||||
config.scriptPath,
|
config.scriptPath,
|
||||||
@@ -86,7 +128,8 @@ export default (app: Router) => {
|
|||||||
logger.error('🔥 error: %o', e);
|
logger.error('🔥 error: %o', e);
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/detail',
|
'/detail',
|
||||||
@@ -100,7 +143,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
const content = await scriptService.getFile(
|
const content = await scriptService.getFile(
|
||||||
req.query?.path as string || '',
|
(req.query?.path as string) || '',
|
||||||
req.query.file as string,
|
req.query.file as string,
|
||||||
);
|
);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
@@ -110,38 +153,108 @@ 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(
|
route.get(
|
||||||
'/:file',
|
'/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({
|
celebrate({
|
||||||
params: Joi.object({
|
|
||||||
file: Joi.string().required(),
|
|
||||||
}),
|
|
||||||
query: Joi.object({
|
query: Joi.object({
|
||||||
path: Joi.string().optional().allow(''),
|
...historyQuery,
|
||||||
|
id: Joi.string().uuid().required(),
|
||||||
}).unknown(true),
|
}).unknown(true),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const scriptService = Container.get(ScriptService);
|
const service = Container.get(ScriptHistoryService);
|
||||||
const content = await scriptService.getFile(
|
const data = await service.detail(
|
||||||
req.query?.path as string || '',
|
(req.query.path as string) || '',
|
||||||
req.params.file,
|
req.query.filename as string,
|
||||||
|
req.query.id as string,
|
||||||
);
|
);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data });
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
return next(e);
|
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) => {
|
||||||
|
return res.send({
|
||||||
|
code: 410,
|
||||||
|
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
route.post(
|
route.post(
|
||||||
'/',
|
'/',
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
},
|
||||||
upload.single('file'),
|
upload.single('file'),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
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(''),
|
||||||
@@ -167,11 +280,7 @@ export default (app: Router) => {
|
|||||||
if (!path.startsWith('/')) {
|
if (!path.startsWith('/')) {
|
||||||
path = join(config.scriptPath, path);
|
path = join(config.scriptPath, path);
|
||||||
}
|
}
|
||||||
if (
|
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||||
config.writePathList.every(
|
|
||||||
(rootPath) => !isPathInside(rootPath, path),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 403,
|
code: 403,
|
||||||
message: t('暂无权限'),
|
message: t('暂无权限'),
|
||||||
@@ -183,7 +292,8 @@ export default (app: Router) => {
|
|||||||
if (!isPathAllowed(uploadPath)) {
|
if (!isPathAllowed(uploadPath)) {
|
||||||
return res.send({ code: 403, message: t('暂无权限') });
|
return res.send({ code: 403, message: t('暂无权限') });
|
||||||
}
|
}
|
||||||
await fs.rename(req.file.path, uploadPath);
|
await fs.copyFile(req.file.path, uploadPath);
|
||||||
|
await fs.unlink(req.file.path);
|
||||||
return res.send({ code: 200 });
|
return res.send({ code: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,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,
|
||||||
@@ -218,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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -230,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) => {
|
||||||
@@ -247,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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -377,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) {
|
||||||
|
|||||||
+19
-12
@@ -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 });
|
||||||
@@ -148,13 +145,27 @@ export default (app: Router) => {
|
|||||||
params: Joi.object({
|
params: Joi.object({
|
||||||
id: Joi.number().required(),
|
id: Joi.number().required(),
|
||||||
}),
|
}),
|
||||||
|
query: Joi.object({
|
||||||
|
offset: Joi.number().integer().min(0).optional(),
|
||||||
|
limit: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(1024 * 1024)
|
||||||
|
.optional(),
|
||||||
|
tail: Joi.boolean().optional(),
|
||||||
|
t: Joi.string().optional(),
|
||||||
|
}).unknown(true),
|
||||||
}),
|
}),
|
||||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const data = await subscriptionService.log(req.params.id);
|
const result = await subscriptionService.log(req.params.id, {
|
||||||
return res.send({ code: 200, data });
|
offset: req.query.offset as unknown as number,
|
||||||
|
limit: req.query.limit as unknown as number,
|
||||||
|
tail: req.query.tail as unknown as boolean,
|
||||||
|
});
|
||||||
|
return res.send({ code: 200, data: result.content, ...result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -190,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 });
|
||||||
|
|||||||
+48
-10
@@ -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`;
|
||||||
@@ -273,8 +287,13 @@ 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
|
||||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
try {
|
||||||
res.end();
|
await logStreamManager.closeStream(
|
||||||
|
await handleLogPath(logPath),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: async (message: string) => {
|
onError: async (message: string) => {
|
||||||
res.write(message);
|
res.write(message);
|
||||||
@@ -350,6 +369,11 @@ export default (app: Router) => {
|
|||||||
query: {
|
query: {
|
||||||
startTime: Joi.string().allow('').optional(),
|
startTime: Joi.string().allow('').optional(),
|
||||||
endTime: Joi.string().allow('').optional(),
|
endTime: Joi.string().allow('').optional(),
|
||||||
|
limit: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(1024 * 1024)
|
||||||
|
.optional(),
|
||||||
t: Joi.string().optional(),
|
t: Joi.string().optional(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -361,6 +385,7 @@ export default (app: Router) => {
|
|||||||
req.query as {
|
req.query as {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
limit?: number;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -369,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) => {
|
||||||
|
|||||||
+86
-13
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
|
|||||||
cb(null, key + ext);
|
cb(null, key + ext);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const imageTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
};
|
||||||
|
if (imageTypes[ext] !== file.mimetype) {
|
||||||
|
return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片')));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
app.use('/user', route);
|
app.use('/user', route);
|
||||||
@@ -35,8 +53,8 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -70,8 +88,8 @@ export default (app: Router) => {
|
|||||||
'/',
|
'/',
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -80,8 +98,8 @@ export default (app: Router) => {
|
|||||||
return res.send({ code: 450, message: t('未知错误') });
|
return res.send({ code: 450, message: t('未知错误') });
|
||||||
}
|
}
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.updateUsernameAndPassword(req.body);
|
const result = await userService.updateUsernameAndPassword(req.body);
|
||||||
res.send({ code: 200, message: t('更新成功') });
|
res.send(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
@@ -156,11 +174,12 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/two-factor/login',
|
'/two-factor/login',
|
||||||
|
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
code: Joi.string().required(),
|
code: Joi.string().required(),
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -189,6 +208,59 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/ip-blacklist',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const userService = Container.get(UserService);
|
||||||
|
const data = await userService.getIpBlacklist();
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.put(
|
||||||
|
'/ip-blacklist',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
ip: Joi.string()
|
||||||
|
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||||
|
.required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const userService = Container.get(UserService);
|
||||||
|
const data = await userService.blockIp(req.body.ip);
|
||||||
|
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.delete(
|
||||||
|
'/ip-blacklist',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
ip: Joi.string()
|
||||||
|
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||||
|
.required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const userService = Container.get(UserService);
|
||||||
|
const data = await userService.unblockIp(req.body.ip);
|
||||||
|
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/notification',
|
'/notification',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -219,18 +291,19 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/init',
|
'/init',
|
||||||
|
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
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 {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
await userService.updateUsernameAndPassword(req.body);
|
const result = await userService.initializeUser(req.body);
|
||||||
res.send({ code: 200, message: t('更新成功') });
|
res.send(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-98
@@ -1,14 +1,11 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import cluster, { type Worker } from 'cluster';
|
import cluster, { type Worker } from 'cluster';
|
||||||
import compression from 'compression';
|
import type express from 'express';
|
||||||
import cors from 'cors';
|
|
||||||
import express from 'express';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import Logger from './loaders/logger';
|
import Logger from './loaders/logger';
|
||||||
import { monitoringMiddleware } from './middlewares/monitoring';
|
import { errStack } from './shared/errors';
|
||||||
import { errStack } from './config/util';
|
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';
|
||||||
|
|
||||||
@@ -20,35 +17,18 @@ interface WorkerMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Application {
|
class Application {
|
||||||
private app: express.Application;
|
|
||||||
private httpServerService?: HttpServerService;
|
private httpServerService?: HttpServerService;
|
||||||
private grpcServerService?: GrpcServerService;
|
private grpcServerService?: GrpcServerService;
|
||||||
private manualPrimaryRuntime?: {
|
|
||||||
stop(): Promise<'drained' | 'timed_out'>;
|
|
||||||
};
|
|
||||||
private legacyShadowCaptureEvidence?: {
|
|
||||||
close(): Promise<unknown>;
|
|
||||||
};
|
|
||||||
private isShuttingDown = false;
|
private isShuttingDown = false;
|
||||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||||
private httpWorker?: Worker;
|
private httpWorker?: Worker;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.app = express();
|
|
||||||
// 创建一个全局中间件,删除查询参数中的t
|
|
||||||
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
if (req.query.t) {
|
|
||||||
delete req.query.t;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
try {
|
try {
|
||||||
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 {
|
||||||
@@ -61,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');
|
||||||
@@ -85,8 +72,13 @@ class Application {
|
|||||||
);
|
);
|
||||||
// If gRPC worker died, restart it and wait for it to be ready
|
// If gRPC worker died, restart it and wait for it to be ready
|
||||||
if (metadata.serviceType === 'grpc') {
|
if (metadata.serviceType === 'grpc') {
|
||||||
|
try {
|
||||||
|
this.httpWorker?.send('scheduler-unavailable');
|
||||||
|
} catch (error) {
|
||||||
|
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
|
||||||
@@ -138,7 +130,14 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private forkWorker(serviceType: string): Worker {
|
private forkWorker(serviceType: string): Worker {
|
||||||
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
|
||||||
|
// PM2's fork launcher is inherited by our own cluster workers. Their APM
|
||||||
|
// messages go to this primary, not PM2, and duplicate its sampling work.
|
||||||
|
// Keep primary monitoring and allow restoring the inherited worker APM.
|
||||||
|
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
|
||||||
|
workerEnv.pmx = 'false';
|
||||||
|
}
|
||||||
|
const worker = cluster.fork(workerEnv);
|
||||||
|
|
||||||
this.workerMetadataMap.set(worker.id, {
|
this.workerMetadataMap.set(worker.id, {
|
||||||
id: worker.id,
|
id: worker.id,
|
||||||
@@ -151,17 +150,13 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async initializeDatabase() {
|
private async initializeDatabase() {
|
||||||
const dbLoader = await import('./loaders/db');
|
const { runStartupProcess } = await import('./shared/startupProcess');
|
||||||
await dbLoader.default();
|
await runStartupProcess(require.resolve('./bootstrap/database'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupMiddlewares() {
|
private async setupMiddlewares(): Promise<express.Application> {
|
||||||
this.app.use(helmet({
|
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||||
contentSecurityPolicy: false,
|
return createHttpApplication();
|
||||||
}));
|
|
||||||
this.app.use(cors(config.cors));
|
|
||||||
this.app.use(compression());
|
|
||||||
this.app.use(monitoringMiddleware);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupMasterShutdown() {
|
private setupMasterShutdown() {
|
||||||
@@ -240,41 +235,18 @@ class Application {
|
|||||||
const { initGrpcCerts } = await import('./config/grpcCerts');
|
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||||
await initGrpcCerts();
|
await initGrpcCerts();
|
||||||
|
|
||||||
this.setupMiddlewares();
|
const app = await this.setupMiddlewares();
|
||||||
|
|
||||||
const { HttpServerService } = await import('./services/http');
|
const { HttpServerService } = await import('./services/http');
|
||||||
this.httpServerService = Container.get(HttpServerService);
|
this.httpServerService = Container.get(HttpServerService);
|
||||||
|
|
||||||
const appLoader = await import('./loaders/app');
|
const appLoader = await import('./loaders/app');
|
||||||
await appLoader.default({ app: this.app });
|
await appLoader.default({ app });
|
||||||
|
|
||||||
const { bootstrapLegacyShadowStartupReconciliation } = await import(
|
const server = await this.httpServerService.initialize(
|
||||||
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
|
app,
|
||||||
|
config.port,
|
||||||
);
|
);
|
||||||
const legacyShadowStartup =
|
|
||||||
await bootstrapLegacyShadowStartupReconciliation();
|
|
||||||
|
|
||||||
const { bootstrapLegacyShadowCaptureEvidence } = await import(
|
|
||||||
'./runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence'
|
|
||||||
);
|
|
||||||
this.legacyShadowCaptureEvidence =
|
|
||||||
await bootstrapLegacyShadowCaptureEvidence({
|
|
||||||
startup: legacyShadowStartup,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { bootstrapDefaultManualPrimaryRuntime } = await import(
|
|
||||||
'./runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime'
|
|
||||||
);
|
|
||||||
this.manualPrimaryRuntime = await bootstrapDefaultManualPrimaryRuntime();
|
|
||||||
|
|
||||||
let server;
|
|
||||||
try {
|
|
||||||
server = await this.httpServerService.initialize(this.app, config.port);
|
|
||||||
} catch (error) {
|
|
||||||
await this.stopManualPrimaryRuntime();
|
|
||||||
await this.closeLegacyShadowCaptureEvidence();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverLoader = await import('./loaders/server');
|
const serverLoader = await import('./loaders/server');
|
||||||
await (serverLoader.default as any)({ server });
|
await (serverLoader.default as any)({ server });
|
||||||
@@ -293,17 +265,10 @@ class Application {
|
|||||||
process.on('message', async (msg) => {
|
process.on('message', async (msg) => {
|
||||||
if (msg === 'shutdown') {
|
if (msg === 'shutdown') {
|
||||||
this.gracefulShutdown(serviceType);
|
this.gracefulShutdown(serviceType);
|
||||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
} else if (serviceType === 'http' &&
|
||||||
// Re-register cron jobs when gRPC worker restarts
|
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
|
||||||
try {
|
const { default: cronClient } = await import('./schedule/client');
|
||||||
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
cronClient.readiness.invalidate();
|
||||||
const CronService = (await import('./services/cron')).default;
|
|
||||||
const cronService = Container.get(CronService);
|
|
||||||
await cronService.autosave_crontab();
|
|
||||||
Logger.info('[boot] Cron jobs re-registered successfully');
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,9 +283,7 @@ class Application {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (serviceType === 'http') {
|
if (serviceType === 'http') {
|
||||||
await this.stopManualPrimaryRuntime();
|
|
||||||
await this.httpServerService?.shutdown();
|
await this.httpServerService?.shutdown();
|
||||||
await this.closeLegacyShadowCaptureEvidence();
|
|
||||||
} else {
|
} else {
|
||||||
await this.grpcServerService?.shutdown();
|
await this.grpcServerService?.shutdown();
|
||||||
}
|
}
|
||||||
@@ -330,27 +293,6 @@ class Application {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopManualPrimaryRuntime(): Promise<void> {
|
|
||||||
const runtime = this.manualPrimaryRuntime;
|
|
||||||
this.manualPrimaryRuntime = undefined;
|
|
||||||
if (!runtime) return;
|
|
||||||
try {
|
|
||||||
const result = await runtime.stop();
|
|
||||||
if (result === 'timed_out') {
|
|
||||||
Logger.warn('[runtime-activation] shutdown timed out');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Logger.error('[runtime-activation] shutdown failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async closeLegacyShadowCaptureEvidence(): Promise<void> {
|
|
||||||
const evidence = this.legacyShadowCaptureEvidence;
|
|
||||||
this.legacyShadowCaptureEvidence = undefined;
|
|
||||||
if (!evidence) return;
|
|
||||||
await evidence.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { errStack } from '../shared/errors';
|
||||||
|
// Also terminate if the primary disappears without forwarding a signal.
|
||||||
|
const onParentDisconnect = () => process.exit(1);
|
||||||
|
process.once('disconnect', onParentDisconnect);
|
||||||
|
async function initializeDatabase() {
|
||||||
|
const { database } = await import('../data');
|
||||||
|
try {
|
||||||
|
const { default: loadDatabase } = await import('../loaders/db');
|
||||||
|
await loadDatabase();
|
||||||
|
} finally {
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initializeDatabase()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
process.removeListener('disconnect', onParentDisconnect);
|
||||||
|
// Let pending log writes drain and exit naturally after closing SQLite.
|
||||||
|
if (process.connected) process.disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// A single, disposable backend process. Resolve startup dependencies, but never
|
||||||
|
// run appLoader: its initData/initTask hooks install dependencies and start user work.
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import dns from 'dns';
|
||||||
|
import http from 'http';
|
||||||
|
// The first scheduler connection can outlive its short RPC deadline. Let the
|
||||||
|
// normal single-flight recovery finish within the supervised startup budget.
|
||||||
|
async function waitForUpgradeScheduler(
|
||||||
|
readiness: {
|
||||||
|
recover(): Promise<boolean>;
|
||||||
|
},
|
||||||
|
timeoutMs: number,
|
||||||
|
retryMs = 250,
|
||||||
|
) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
do {
|
||||||
|
if (await readiness.recover()) return;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, Math.min(retryMs, remaining)),
|
||||||
|
);
|
||||||
|
} while (Date.now() < deadline);
|
||||||
|
throw new Error('Upgrade scheduler readiness failed');
|
||||||
|
}
|
||||||
|
async function probeUpgrade() {
|
||||||
|
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||||
|
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||||
|
dns.setDefaultResultOrder('ipv4first');
|
||||||
|
const { default: config } = await import('../config');
|
||||||
|
const { database } = await import('../data');
|
||||||
|
const { default: loadDatabase } = await import('../loaders/db');
|
||||||
|
const { Container } = await import('typedi');
|
||||||
|
const { GrpcServerService } = await import('../services/grpc');
|
||||||
|
const { HttpServerService } = await import('../services/http');
|
||||||
|
const { createHttpApplication } = await import('../loaders/httpApplication');
|
||||||
|
const { default: loadServer } = await import('../loaders/server');
|
||||||
|
const { default: loadExpress } = await import('../loaders/express');
|
||||||
|
await import('../loaders/app'); // Resolve normal startup hooks without running them.
|
||||||
|
const { default: inject } = await import('../loaders/depInjector');
|
||||||
|
const grpc = Container.get(GrpcServerService);
|
||||||
|
const server = Container.get(HttpServerService);
|
||||||
|
try {
|
||||||
|
await loadDatabase();
|
||||||
|
// Exercise an actual on-disk transaction as well as schema initialization.
|
||||||
|
await database.transaction(async () => {
|
||||||
|
await database.query('CREATE TABLE upgrade_probe (value INTEGER)');
|
||||||
|
await database.query('INSERT INTO upgrade_probe VALUES (42)');
|
||||||
|
const rows = await database.query('SELECT value FROM upgrade_probe');
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
rows[0] as {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
)?.value !== 42
|
||||||
|
)
|
||||||
|
throw new Error('Upgrade database readback failed');
|
||||||
|
});
|
||||||
|
inject();
|
||||||
|
config.grpcPort = await grpc.initialize();
|
||||||
|
const { default: cronClient } = await import('../schedule/client');
|
||||||
|
cronClient.readiness.configure(async () => {});
|
||||||
|
await waitForUpgradeScheduler(
|
||||||
|
cronClient.readiness,
|
||||||
|
process.arch === 'arm' &&
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
process.config.variables as {
|
||||||
|
arm_version?: string | number;
|
||||||
|
}
|
||||||
|
).arm_version,
|
||||||
|
) === 6
|
||||||
|
? 60000
|
||||||
|
: 10000,
|
||||||
|
);
|
||||||
|
const app = await createHttpApplication();
|
||||||
|
loadExpress({ app });
|
||||||
|
const listener = await server.initialize(app, 0);
|
||||||
|
await loadServer({ server: listener });
|
||||||
|
const address = listener.address();
|
||||||
|
if (!address || typeof address === 'string')
|
||||||
|
throw new Error('No probe HTTP port');
|
||||||
|
const health = await new Promise<any>((resolve, reject) => {
|
||||||
|
const request = http.get(
|
||||||
|
`http://127.0.0.1:${address.port}/api/health`,
|
||||||
|
(response) => {
|
||||||
|
let body = '';
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('error', reject);
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(body);
|
||||||
|
if (
|
||||||
|
response.statusCode !== 200 ||
|
||||||
|
result.code !== 200 ||
|
||||||
|
result.data?.status !== 'ok' ||
|
||||||
|
!result.data.services?.grpc ||
|
||||||
|
!result.data.services?.http
|
||||||
|
)
|
||||||
|
throw new Error('Upgrade HTTP/gRPC health check failed');
|
||||||
|
resolve(result.data);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.setTimeout(10000, () =>
|
||||||
|
request.destroy(new Error('Upgrade health request timed out')),
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
process.send!({
|
||||||
|
type: 'upgrade-probe',
|
||||||
|
ok: true,
|
||||||
|
build: health.build,
|
||||||
|
memory: process.memoryUsage(),
|
||||||
|
pid: process.pid,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await server.shutdown();
|
||||||
|
grpc.getServer().forceShutdown();
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.once('disconnect', () => process.exit(1));
|
||||||
|
probeUpgrade()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
process.send?.({
|
||||||
|
type: 'upgrade-probe',
|
||||||
|
ok: false,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
@@ -51,6 +52,7 @@ export const NotificationModeStringMap = {
|
|||||||
19: 'ntfy',
|
19: 'ntfy',
|
||||||
20: 'wxPusherBot',
|
20: 'wxPusherBot',
|
||||||
21: 'wxPusherSpt',
|
21: 'wxPusherSpt',
|
||||||
|
22: 'wpush',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const LINUX_DEPENDENCE_COMMAND: Record<
|
export const LINUX_DEPENDENCE_COMMAND: Record<
|
||||||
|
|||||||
+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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomInt } from 'crypto';
|
||||||
|
|
||||||
export function createRandomString(min: number, max: number): string {
|
export function createRandomString(min: number, max: number): string {
|
||||||
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
const english = [
|
const english = [
|
||||||
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
|
|||||||
arr.push(getOne(ENGLISH));
|
arr.push(getOne(ENGLISH));
|
||||||
arr.push(getOne(special));
|
arr.push(getOne(special));
|
||||||
|
|
||||||
const len = min + Math.floor(Math.random() * (max - min + 1));
|
const len = min + randomInt(max - min + 1);
|
||||||
|
|
||||||
for (let i = 4; i < len; i++) {
|
for (let i = 4; i < len; i++) {
|
||||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
arr.push(config[randomInt(config.length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newArr = [];
|
const newArr = [];
|
||||||
for (let j = 0; j < len; j++) {
|
for (let j = 0; j < len; j++) {
|
||||||
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
|
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOne(arr: any[]) {
|
function getOne(arr: any[]) {
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return arr[randomInt(arr.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return newArr.join('');
|
return newArr.join('');
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+383
-180
@@ -1,17 +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 {
|
||||||
|
mirrorWriteCommand,
|
||||||
|
normalizeMirror,
|
||||||
|
rewriteMirrorSources,
|
||||||
|
} from '../shared/linuxMirror';
|
||||||
|
|
||||||
export * from './share';
|
export * from './share';
|
||||||
|
|
||||||
@@ -144,7 +152,8 @@ export async function handleLogPath(
|
|||||||
logPath: string,
|
logPath: string,
|
||||||
data: string = '',
|
data: string = '',
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const absolutePath = path.resolve(config.logPath, logPath);
|
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||||
|
if (!absolutePath) throw new Error('Log path is outside the log directory');
|
||||||
const logFileExist = await fileExist(absolutePath);
|
const logFileExist = await fileExist(absolutePath);
|
||||||
if (!logFileExist) {
|
if (!logFileExist) {
|
||||||
await createFile(absolutePath, data);
|
await createFile(absolutePath, data);
|
||||||
@@ -214,48 +223,105 @@ export function dirSort(a: IFile, b: IFile): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_SYSTEM_READ_CONCURRENCY = 32;
|
||||||
|
|
||||||
|
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
|
||||||
|
|
||||||
|
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
|
||||||
|
let activeCount = 0;
|
||||||
|
const queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
const runNext = () => {
|
||||||
|
while (activeCount < concurrency && queue.length > 0) {
|
||||||
|
activeCount += 1;
|
||||||
|
queue.shift()?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <T>(task: () => Promise<T>) =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
queue.push(() => {
|
||||||
|
task()
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => {
|
||||||
|
activeCount -= 1;
|
||||||
|
runNext();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
runNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDirsWithRunner(
|
||||||
|
dir: string,
|
||||||
|
baseDir: string,
|
||||||
|
blacklist: string[],
|
||||||
|
sort: (a: IFile, b: IFile) => number,
|
||||||
|
runFileSystemTask: FileSystemTaskRunner,
|
||||||
|
): Promise<IFile[]> {
|
||||||
|
const relativePath = path.relative(baseDir, dir);
|
||||||
|
const entries = await runFileSystemTask(() =>
|
||||||
|
fs.readdir(dir, { withFileTypes: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = await Promise.all(
|
||||||
|
entries.map(async (entry): Promise<IFile | undefined> => {
|
||||||
|
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subPath = path.join(dir, entry.name);
|
||||||
|
const stats = await runFileSystemTask(() => fs.lstat(subPath));
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const key = path.join(relativePath, entry.name);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const children = await readDirsWithRunner(
|
||||||
|
subPath,
|
||||||
|
baseDir,
|
||||||
|
blacklist,
|
||||||
|
sort,
|
||||||
|
runFileSystemTask,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
title: entry.name,
|
||||||
|
key,
|
||||||
|
type: 'directory',
|
||||||
|
parent: relativePath,
|
||||||
|
createTime: stats.birthtime.getTime(),
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: entry.name,
|
||||||
|
type: 'file',
|
||||||
|
key,
|
||||||
|
parent: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
createTime: stats.birthtime.getTime(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
|
||||||
|
}
|
||||||
|
|
||||||
export async function readDirs(
|
export async function readDirs(
|
||||||
dir: string,
|
dir: string,
|
||||||
baseDir: string = '',
|
baseDir: string = '',
|
||||||
blacklist: string[] = [],
|
blacklist: string[] = [],
|
||||||
sort: (a: IFile, b: IFile) => number = dirSort,
|
sort: (a: IFile, b: IFile) => number = dirSort,
|
||||||
): Promise<IFile[]> {
|
): Promise<IFile[]> {
|
||||||
const relativePath = path.relative(baseDir, dir);
|
return readDirsWithRunner(
|
||||||
const files = await fs.readdir(dir);
|
dir,
|
||||||
const result: IFile[] = [];
|
baseDir,
|
||||||
|
blacklist,
|
||||||
for (const file of files) {
|
sort,
|
||||||
const subPath = path.join(dir, file);
|
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
|
||||||
const stats = await fs.lstat(subPath);
|
);
|
||||||
const key = path.join(relativePath, file);
|
|
||||||
|
|
||||||
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
const children = await readDirs(subPath, baseDir, blacklist, sort);
|
|
||||||
result.push({
|
|
||||||
title: file,
|
|
||||||
key,
|
|
||||||
type: 'directory',
|
|
||||||
parent: relativePath,
|
|
||||||
createTime: stats.birthtime.getTime(),
|
|
||||||
children: children.sort(sort),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result.push({
|
|
||||||
title: file,
|
|
||||||
type: 'file',
|
|
||||||
key,
|
|
||||||
parent: relativePath,
|
|
||||||
size: stats.size,
|
|
||||||
createTime: stats.birthtime.getTime(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort(sort);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readDir(
|
export async function readDir(
|
||||||
@@ -263,26 +329,13 @@ export async function readDir(
|
|||||||
baseDir: string = '',
|
baseDir: string = '',
|
||||||
blacklist: string[] = [],
|
blacklist: string[] = [],
|
||||||
): Promise<IFile[]> {
|
): Promise<IFile[]> {
|
||||||
const absoluteBaseDir = path.resolve(baseDir);
|
|
||||||
const absoluteDir = path.resolve(baseDir, dir);
|
const absoluteDir = path.resolve(baseDir, dir);
|
||||||
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
|
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const relativePath = path.relative(baseDir, absoluteDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [realBaseDir, realDirectory] = await Promise.all([
|
|
||||||
fs.realpath(absoluteBaseDir),
|
|
||||||
fs.realpath(absoluteDir),
|
|
||||||
]);
|
|
||||||
const directoryStat = await fs.lstat(absoluteDir);
|
|
||||||
if (
|
|
||||||
!isPathInside(realBaseDir, realDirectory) ||
|
|
||||||
!directoryStat.isDirectory() ||
|
|
||||||
directoryStat.isSymbolicLink()
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const relativePath = path.relative(absoluteBaseDir, absoluteDir);
|
|
||||||
const files = await fs.readdir(absoluteDir);
|
const files = await fs.readdir(absoluteDir);
|
||||||
const result: IFile[] = [];
|
const result: IFile[] = [];
|
||||||
|
|
||||||
@@ -325,19 +378,6 @@ export async function readDir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPathInside(rootPath: string, targetPath: string): boolean {
|
|
||||||
const relative = path.relative(
|
|
||||||
path.resolve(rootPath),
|
|
||||||
path.resolve(targetPath),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
relative === '' ||
|
|
||||||
(relative !== '..' &&
|
|
||||||
!relative.startsWith(`..${path.sep}`) &&
|
|
||||||
!path.isAbsolute(relative))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function promiseExec(command: string): Promise<string> {
|
export async function promiseExec(command: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stderr, stdout } = await promisify(exec)(command, {
|
const { stderr, stdout } = await promisify(exec)(command, {
|
||||||
@@ -456,18 +496,69 @@ export function psTree(pid: number): Promise<number[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killTask(pid: number) {
|
export async function killTask(pid: number, waitForExit = false) {
|
||||||
const pids = await psTree(pid);
|
const descendants = await psTree(pid);
|
||||||
|
const signal = (target: number, sig: NodeJS.Signals) => {
|
||||||
if (pids.length) {
|
|
||||||
try {
|
try {
|
||||||
[pid, ...pids].reverse().forEach((x) => {
|
process.kill(target, sig);
|
||||||
process.kill(x, 15);
|
} catch (error: any) {
|
||||||
});
|
if (error.code !== 'ESRCH') throw error;
|
||||||
} catch (error) { }
|
}
|
||||||
} else {
|
};
|
||||||
process.kill(pid, 2);
|
if (!waitForExit) {
|
||||||
|
if (descendants.length) {
|
||||||
|
// A child may exit after psTree; keep signalling the remaining tree.
|
||||||
|
for (const target of [pid, ...descendants].reverse()) {
|
||||||
|
signal(target, 'SIGTERM');
|
||||||
|
}
|
||||||
|
} else signal(pid, 'SIGINT');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const pids = [...descendants.reverse(), pid];
|
||||||
|
for (const target of pids) signal(target, 'SIGTERM');
|
||||||
|
const alive = async (target: number) => {
|
||||||
|
try {
|
||||||
|
process.kill(target, 0);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ESRCH') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8');
|
||||||
|
// The command field may contain spaces and parentheses. Zombies have
|
||||||
|
// exited even while their parent has not reaped the PID yet.
|
||||||
|
const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0];
|
||||||
|
if (['Z', 'X', 'x'].includes(state)) return false;
|
||||||
|
} catch {
|
||||||
|
// /proc may be unavailable, or the process may have just exited.
|
||||||
|
// Retain the portable signal probe rather than assuming it is dead.
|
||||||
|
try {
|
||||||
|
process.kill(target, 0);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ESRCH') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const wait = async () => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
let remaining = pids;
|
||||||
|
while (true) {
|
||||||
|
const states = await Promise.all(remaining.map(alive));
|
||||||
|
remaining = remaining.filter((_, index) => states[index]);
|
||||||
|
if (!remaining.length || Date.now() >= deadline) return remaining;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let remaining = await wait();
|
||||||
|
if (!remaining.length) return;
|
||||||
|
for (const target of remaining) signal(target, 'SIGKILL');
|
||||||
|
remaining = await wait();
|
||||||
|
if (remaining.length)
|
||||||
|
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPid(cmd: string) {
|
export async function getPid(cmd: string) {
|
||||||
@@ -567,11 +658,7 @@ export function safeJSONParse(value?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errStack(error: unknown): string {
|
export { errStack } from '../shared/errors';
|
||||||
return error instanceof Error && error.stack
|
|
||||||
? error.stack
|
|
||||||
: String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rmPath(path: string) {
|
export async function rmPath(path: string) {
|
||||||
try {
|
try {
|
||||||
@@ -590,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;
|
||||||
@@ -600,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()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,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> {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { database } from '.';
|
||||||
|
import { DatabaseClient } from './databaseClient';
|
||||||
|
|
||||||
|
// Keep the existing Keyv serialization and namespace. Only its SQLite transport
|
||||||
|
// changes; old cached authentication records remain readable in either version.
|
||||||
|
export class CacheStore extends EventEmitter {
|
||||||
|
readonly opts = { dialect: 'sqlite' };
|
||||||
|
namespace?: string;
|
||||||
|
constructor(private client: () => DatabaseClient = () => database) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
get(key: string): Promise<string | undefined> {
|
||||||
|
return this.client().request('cache', 'get', key);
|
||||||
|
}
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
return this.client().request('cache', 'set', key, value);
|
||||||
|
}
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return this.client().request('cache', 'delete', key);
|
||||||
|
}
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
throw new Error('Clearing all authentication namespaces is not supported');
|
||||||
|
}
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
/* The shared database client owns the worker. */
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-35
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
|
|
||||||
export class Crontab {
|
export class Crontab {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -13,6 +12,7 @@ export class Crontab {
|
|||||||
pid?: number;
|
pid?: number;
|
||||||
isDisabled?: 1 | 0;
|
isDisabled?: 1 | 0;
|
||||||
log_path?: string;
|
log_path?: string;
|
||||||
|
queued_token?: string | null;
|
||||||
isPinned?: 1 | 0;
|
isPinned?: 1 | 0;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
last_running_time?: number;
|
last_running_time?: number;
|
||||||
@@ -61,36 +61,5 @@ export enum CrontabStatus {
|
|||||||
'queued' = 3,
|
'queued' = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
|
export type CronInstance = RecordRow<Crontab>;
|
||||||
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
export const CrontabModel = new Repository<Crontab>('Crontab');
|
||||||
name: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
command: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
schedule: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
timestamp: DataTypes.STRING,
|
|
||||||
saved: DataTypes.BOOLEAN,
|
|
||||||
status: DataTypes.NUMBER,
|
|
||||||
isSystem: DataTypes.NUMBER,
|
|
||||||
pid: DataTypes.NUMBER,
|
|
||||||
isDisabled: DataTypes.NUMBER,
|
|
||||||
isPinned: DataTypes.NUMBER,
|
|
||||||
log_path: DataTypes.STRING,
|
|
||||||
labels: DataTypes.JSON,
|
|
||||||
last_running_time: DataTypes.NUMBER,
|
|
||||||
last_execution_time: DataTypes.NUMBER,
|
|
||||||
sub_id: { type: DataTypes.NUMBER, allowNull: true },
|
|
||||||
extra_schedules: DataTypes.JSON,
|
|
||||||
task_before: DataTypes.STRING,
|
|
||||||
task_after: DataTypes.STRING,
|
|
||||||
log_name: DataTypes.STRING,
|
|
||||||
allow_multiple_instances: DataTypes.NUMBER,
|
|
||||||
work_dir: DataTypes.STRING,
|
|
||||||
});
|
|
||||||
|
|||||||
+3
-48
@@ -1,5 +1,4 @@
|
|||||||
import { DataTypes, Model } from 'sequelize';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { sequelize } from '.';
|
|
||||||
|
|
||||||
export class CrontabStat {
|
export class CrontabStat {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -23,49 +22,5 @@ export class CrontabStat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
|
export type CrontabStatInstance = RecordRow<CrontabStat>;
|
||||||
|
export const CrontabStatModel = new Repository<CrontabStat>('CrontabStat');
|
||||||
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
|
|
||||||
'CrontabStat',
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true,
|
|
||||||
},
|
|
||||||
ref_id: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
date: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
run_count: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
success_count: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
fail_count: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
total_time: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
max_time: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
indexes: [
|
|
||||||
{ unique: true, fields: ['ref_id', 'date'] },
|
|
||||||
{ fields: ['date'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
+3
-23
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model } from 'sequelize';
|
|
||||||
|
|
||||||
export enum CronViewType {
|
export enum CronViewType {
|
||||||
'系统' = 1,
|
'系统' = 1,
|
||||||
@@ -39,24 +38,5 @@ export class CrontabView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronViewInstance
|
export type CronViewInstance = RecordRow<CrontabView>;
|
||||||
extends Model<CrontabView, CrontabView>,
|
export const CrontabViewModel = new Repository<CrontabView>('CrontabView');
|
||||||
CrontabView {}
|
|
||||||
export const CrontabViewModel = sequelize.define<CronViewInstance>(
|
|
||||||
'CrontabView',
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
unique: 'name',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
position: DataTypes.NUMBER,
|
|
||||||
isDisabled: DataTypes.NUMBER,
|
|
||||||
filters: DataTypes.JSON,
|
|
||||||
sorts: DataTypes.JSON,
|
|
||||||
filterRelation: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { AsyncLocalStorage } from 'async_hooks';
|
||||||
|
import { ChildProcess, fork } from 'child_process';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { setTimeout, clearTimeout } from 'timers';
|
||||||
|
import { DatabaseError } from './databaseErrors';
|
||||||
|
|
||||||
|
type Scope = { active: boolean; child: boolean; token: number };
|
||||||
|
type Reply = {
|
||||||
|
id: number;
|
||||||
|
value?: any;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
};
|
||||||
|
type Limits = { timeoutMs?: number; queueLimit?: number };
|
||||||
|
|
||||||
|
// One worker per backend process; SQLite itself coordinates independent processes.
|
||||||
|
// A transaction owns this client's queue until its callback and commit finish.
|
||||||
|
export class DatabaseClient {
|
||||||
|
private worker?: ChildProcess;
|
||||||
|
private stopped: Promise<void> = Promise.resolve();
|
||||||
|
private failed?: Error;
|
||||||
|
private closed = false;
|
||||||
|
private nextId = 0;
|
||||||
|
private queued = 0;
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
private pending = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve(value: any): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
private scope = new AsyncLocalStorage<Scope>();
|
||||||
|
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private readonly queueLimit: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly storage: string,
|
||||||
|
private cacheStorage?: string,
|
||||||
|
limits: Limits = {},
|
||||||
|
) {
|
||||||
|
this.timeoutMs =
|
||||||
|
limits.timeoutMs ?? (process.arch === 'arm' ? 300000 : 60000);
|
||||||
|
this.queueLimit = limits.queueLimit ?? 256;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(this.timeoutMs) ||
|
||||||
|
this.timeoutMs < 1 ||
|
||||||
|
!Number.isSafeInteger(this.queueLimit) ||
|
||||||
|
this.queueLimit < 1
|
||||||
|
)
|
||||||
|
throw new Error('Invalid database worker limits');
|
||||||
|
}
|
||||||
|
|
||||||
|
private abort(error: Error) {
|
||||||
|
this.failed ||= error;
|
||||||
|
for (const request of this.pending.values()) {
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
request.reject(this.failed);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.unref();
|
||||||
|
this.worker?.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
|
private unref() {
|
||||||
|
this.worker?.unref();
|
||||||
|
this.worker?.channel?.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
private start() {
|
||||||
|
if (this.worker) return this.worker;
|
||||||
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||||
|
if (major < 22 || (major === 22 && minor < 17))
|
||||||
|
throw new DatabaseError(
|
||||||
|
'Qinglong requires Node >=22.17; keep the previous installation until Node is upgraded',
|
||||||
|
'UNSUPPORTED_NODE',
|
||||||
|
);
|
||||||
|
// ts-node's CLI places its executable in execArgv, rather than using -r.
|
||||||
|
// Source workers register their own compiler; compiled workers need none.
|
||||||
|
const preloads = __filename.endsWith('.ts')
|
||||||
|
? ['--require', require.resolve('ts-node/register/transpile-only')]
|
||||||
|
: [];
|
||||||
|
const armv6 =
|
||||||
|
process.arch === 'arm' &&
|
||||||
|
Number(
|
||||||
|
(process.config.variables as { arm_version?: string | number })
|
||||||
|
.arm_version,
|
||||||
|
) === 6;
|
||||||
|
const worker = fork(require.resolve('./databaseProcess'), [], {
|
||||||
|
serialization: 'advanced',
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
|
execArgv: [
|
||||||
|
...preloads,
|
||||||
|
...(armv6 ? ['--jitless'] : []),
|
||||||
|
'--max-old-space-size=128',
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_OPTIONS: '',
|
||||||
|
...(__filename.endsWith('.ts')
|
||||||
|
? { TS_NODE_PROJECT: join(__dirname, 'tsconfig.worker.json') }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.stopped = new Promise((resolve) =>
|
||||||
|
worker.once('close', () => resolve()),
|
||||||
|
);
|
||||||
|
const onOwnerExit = () => worker.kill('SIGKILL');
|
||||||
|
process.once('exit', onOwnerExit);
|
||||||
|
worker.once('close', () => process.removeListener('exit', onOwnerExit));
|
||||||
|
this.worker = worker;
|
||||||
|
worker.on('message', (reply: Reply & { type?: string }) => {
|
||||||
|
if (reply.type === 'fatal' && reply.error) {
|
||||||
|
this.abort(new DatabaseError(reply.error.message, reply.error.code));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = this.pending.get(reply.id);
|
||||||
|
if (!request) return;
|
||||||
|
this.pending.delete(reply.id);
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
if (!this.pending.size) this.unref();
|
||||||
|
if (reply.error)
|
||||||
|
request.reject(
|
||||||
|
new DatabaseError(reply.error.message, reply.error.code),
|
||||||
|
);
|
||||||
|
else request.resolve(reply.value);
|
||||||
|
});
|
||||||
|
const fail = () =>
|
||||||
|
this.abort(
|
||||||
|
new DatabaseError(
|
||||||
|
'Database worker stopped; restart the service before retrying a write',
|
||||||
|
'DATABASE_WORKER_EXIT',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
worker.on('error', fail);
|
||||||
|
worker.on('exit', () => {
|
||||||
|
if (!this.closed) fail();
|
||||||
|
});
|
||||||
|
worker.send({
|
||||||
|
storage: this.storage,
|
||||||
|
cacheStorage: this.cacheStorage,
|
||||||
|
ownerPid: process.pid,
|
||||||
|
});
|
||||||
|
this.unref();
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(
|
||||||
|
operation: string,
|
||||||
|
args: any[] = [],
|
||||||
|
token?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
if (this.failed) return Promise.reject(this.failed);
|
||||||
|
if (this.closed)
|
||||||
|
return Promise.reject(
|
||||||
|
new DatabaseError('Database is closed', 'DATABASE_CLOSED'),
|
||||||
|
);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const worker = this.start();
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
this.abort(
|
||||||
|
new DatabaseError(
|
||||||
|
'Database worker timed out; writes are never automatically replayed',
|
||||||
|
'DATABASE_WORKER_TIMEOUT',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
timer.unref();
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
worker.ref();
|
||||||
|
worker.channel?.ref();
|
||||||
|
try {
|
||||||
|
worker.send({ id, operation, args, token }, (error) => {
|
||||||
|
if (error)
|
||||||
|
this.abort(
|
||||||
|
new DatabaseError(
|
||||||
|
'Database IPC disconnected',
|
||||||
|
'DATABASE_WORKER_EXIT',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!this.pending.size) this.unref();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private exclusive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
shutdown = false,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!shutdown && this.queued >= this.queueLimit)
|
||||||
|
return Promise.reject(
|
||||||
|
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
|
||||||
|
);
|
||||||
|
this.queued++;
|
||||||
|
const previous = this.tail;
|
||||||
|
let release!: () => void;
|
||||||
|
this.tail = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return previous.then(operation).finally(() => {
|
||||||
|
this.queued--;
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request<T = any>(operation: string, ...args: any[]): Promise<T> {
|
||||||
|
const scope = this.scope.getStore();
|
||||||
|
if (scope) {
|
||||||
|
if (!scope.active || scope.child)
|
||||||
|
return Promise.reject(
|
||||||
|
new DatabaseError(
|
||||||
|
'Transaction scope is no longer available',
|
||||||
|
'TRANSACTION_SCOPE',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (this.pending.size >= this.queueLimit)
|
||||||
|
return Promise.reject(
|
||||||
|
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
|
||||||
|
);
|
||||||
|
return this.send(operation, args, scope.token);
|
||||||
|
}
|
||||||
|
return this.exclusive(() => this.send(operation, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const parent = this.scope.getStore();
|
||||||
|
if (parent && (!parent.active || parent.child))
|
||||||
|
throw new DatabaseError(
|
||||||
|
'Concurrent or expired nested transaction',
|
||||||
|
'TRANSACTION_SCOPE',
|
||||||
|
);
|
||||||
|
const run = async () => {
|
||||||
|
const token = ++this.nextId;
|
||||||
|
if (parent) parent.child = true;
|
||||||
|
const scope: Scope = { active: true, child: false, token };
|
||||||
|
let began = false;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
await this.send('begin', [], token);
|
||||||
|
began = true;
|
||||||
|
const deadline = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
reject(
|
||||||
|
new DatabaseError(
|
||||||
|
'Database transaction timed out',
|
||||||
|
'DATABASE_TRANSACTION_TIMEOUT',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const value = await Promise.race([
|
||||||
|
this.scope.run(scope, operation),
|
||||||
|
deadline,
|
||||||
|
]);
|
||||||
|
scope.active = false;
|
||||||
|
await this.send('commit', [], token);
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
scope.active = false;
|
||||||
|
if (began) {
|
||||||
|
try {
|
||||||
|
await this.send('rollback', [], token);
|
||||||
|
} catch {
|
||||||
|
this.abort(
|
||||||
|
new DatabaseError(
|
||||||
|
'Transaction rollback failed; restart the service',
|
||||||
|
'DATABASE_ROLLBACK_FAILED',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (parent) parent.child = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return parent ? run() : this.exclusive(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
return this.request('query', sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
get healthy() {
|
||||||
|
return !this.failed && !this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.scope.getStore())
|
||||||
|
throw new DatabaseError(
|
||||||
|
'Cannot close within a transaction',
|
||||||
|
'TRANSACTION_SCOPE',
|
||||||
|
);
|
||||||
|
await this.exclusive(async () => {
|
||||||
|
if (this.closed) return;
|
||||||
|
try {
|
||||||
|
if (this.worker && !this.failed) await this.send('close');
|
||||||
|
} finally {
|
||||||
|
this.closed = true;
|
||||||
|
this.worker?.ref();
|
||||||
|
this.worker?.kill('SIGKILL');
|
||||||
|
await this.stopped;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Error details (SQL, bindings, credentials) stay inside the database worker.
|
||||||
|
export class DatabaseError extends Error {
|
||||||
|
constructor(message: string, public readonly code: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DatabaseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function databaseStartupFailure(value: unknown) {
|
||||||
|
const error = (value && typeof value === 'object' ? value : {}) as Error & { code?: string };
|
||||||
|
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
|
||||||
|
error.message || '',
|
||||||
|
)?.[1];
|
||||||
|
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(
|
||||||
|
error.message || '',
|
||||||
|
)?.[1];
|
||||||
|
const dependency =
|
||||||
|
/MODULE_NOT_FOUND/.test(error.code || '') &&
|
||||||
|
Boolean(
|
||||||
|
missing &&
|
||||||
|
((!missing.startsWith('.') &&
|
||||||
|
!missing.startsWith('/') &&
|
||||||
|
!missing.startsWith('node:')) ||
|
||||||
|
/node_modules[/\\]/.test(missing) ||
|
||||||
|
/node_modules[/\\]/.test(importer || '')),
|
||||||
|
);
|
||||||
|
return dependency
|
||||||
|
? {
|
||||||
|
code: 'MODULE_NOT_FOUND',
|
||||||
|
message: "Cannot find module '@mikro-orm/sql'",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
code: 'DATABASE_WORKER_EXIT',
|
||||||
|
message: 'Database process failed to start',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { databaseStartupFailure } from './databaseErrors';
|
||||||
|
import { Worker } from 'worker_threads';
|
||||||
|
|
||||||
|
// IPC disconnect cannot run while DatabaseSync is executing SQL. Start this
|
||||||
|
// independent, allocation-light monitor before opening SQLite so a SIGKILLed
|
||||||
|
// backend cannot leave an orphan holding the old service's database write lock.
|
||||||
|
async function monitorOwner(ownerPid: number) {
|
||||||
|
if (!Number.isSafeInteger(ownerPid) || ownerPid < 1)
|
||||||
|
throw new Error('Invalid database owner');
|
||||||
|
const monitor = new Worker(
|
||||||
|
`
|
||||||
|
const {parentPort,workerData}=require('node:worker_threads');
|
||||||
|
process.on('uncaughtException',()=>process.kill(process.pid,'SIGKILL'));
|
||||||
|
const check=()=>{
|
||||||
|
if(process.ppid!==workerData.ownerPid)process.kill(process.pid,'SIGKILL');
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
setInterval(check,500);
|
||||||
|
parentPort.postMessage('ready');
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
eval: true,
|
||||||
|
workerData: { ownerPid },
|
||||||
|
// V8's process-wide --jitless remains active on ARMv6. Do not load the
|
||||||
|
// source worker's ts-node compiler into this tiny, plain-JavaScript monitor.
|
||||||
|
execArgv: [],
|
||||||
|
resourceLimits: {
|
||||||
|
maxOldGenerationSizeMb: 8,
|
||||||
|
maxYoungGenerationSizeMb: 2,
|
||||||
|
stackSizeMb: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
monitor.once('exit', () => process.exit(1));
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
monitor.once('message', () => resolve());
|
||||||
|
monitor.once('error', reject);
|
||||||
|
});
|
||||||
|
monitor.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bootstrap catches dependency import errors before the ORM module loads.
|
||||||
|
// The advanced IPC serializer preserves Date, Buffer and undefined values.
|
||||||
|
process.once('disconnect', () => process.exit(1));
|
||||||
|
process.once('message', async (settings) => {
|
||||||
|
const pending: unknown[] = [];
|
||||||
|
const buffer = (request: unknown) => pending.push(request);
|
||||||
|
// Keep a listener throughout asynchronous monitor startup. Node may flush
|
||||||
|
// several buffered IPC messages while delivering the initial settings.
|
||||||
|
process.on('message', buffer);
|
||||||
|
try {
|
||||||
|
await monitorOwner((settings as { ownerPid: number }).ownerPid);
|
||||||
|
require('./databaseWorker').startDatabaseWorker(settings);
|
||||||
|
process.removeListener('message', buffer);
|
||||||
|
for (const request of pending) process.emit('message', request, undefined);
|
||||||
|
} catch (error) {
|
||||||
|
process.send?.({ type: 'fatal', error: databaseStartupFailure(error) });
|
||||||
|
} finally {
|
||||||
|
process.removeListener('message', buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { EntitySchema, Type } from '@mikro-orm/core';
|
||||||
|
import { MikroORM, SqliteDriver, NodeSqliteDialect, raw } from '@mikro-orm/sql';
|
||||||
|
import { entityDefinitions } from './schema';
|
||||||
|
|
||||||
|
// Sequelize DATE readers expect TEXT. Numeric epoch dates would break rollback.
|
||||||
|
class LegacyDateType extends Type<Date, string> {
|
||||||
|
convertToDatabaseValue(value: Date) {
|
||||||
|
return value == null
|
||||||
|
? value
|
||||||
|
: value.toISOString().replace('T', ' ').replace('Z', ' +00:00');
|
||||||
|
}
|
||||||
|
convertToJSValue(value: string | Date) {
|
||||||
|
return value == null
|
||||||
|
? value
|
||||||
|
: value instanceof Date
|
||||||
|
? value
|
||||||
|
: new Date(value);
|
||||||
|
}
|
||||||
|
getColumnType() {
|
||||||
|
return 'DATETIME';
|
||||||
|
}
|
||||||
|
compareAsType() {
|
||||||
|
return 'string' as const;
|
||||||
|
}
|
||||||
|
get runtimeType() {
|
||||||
|
return 'Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = Object.values(entityDefinitions).map(
|
||||||
|
(definition) =>
|
||||||
|
new EntitySchema({
|
||||||
|
...definition,
|
||||||
|
properties: Object.fromEntries(
|
||||||
|
Object.entries(definition.properties).map(([name, field]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
...field,
|
||||||
|
type:
|
||||||
|
field.type === 'legacyDate' ? new LegacyDateType() : field.type,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
} as any),
|
||||||
|
);
|
||||||
|
let orm: Awaited<ReturnType<typeof MikroORM.init<SqliteDriver>>>;
|
||||||
|
let cache: any;
|
||||||
|
const transactions: number[] = [];
|
||||||
|
let ready: Promise<void>;
|
||||||
|
let workerData: { storage: string; cacheStorage?: string; ownerPid: number };
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (workerData.storage !== ':memory:')
|
||||||
|
mkdirSync(dirname(workerData.storage), { recursive: true });
|
||||||
|
orm = await MikroORM.init<SqliteDriver>({
|
||||||
|
driver: SqliteDriver,
|
||||||
|
driverOptions: new NodeSqliteDialect(workerData.storage),
|
||||||
|
dbName: workerData.storage,
|
||||||
|
entities,
|
||||||
|
debug: false,
|
||||||
|
allowGlobalContext: false,
|
||||||
|
discovery: { warnWhenNoEntities: false },
|
||||||
|
});
|
||||||
|
await orm.em.getConnection().execute('PRAGMA busy_timeout=10000');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter(name: keyof typeof entityDefinitions, value: any): any {
|
||||||
|
if (value === undefined) throw new Error('Undefined database predicate');
|
||||||
|
if (value == null || typeof value !== 'object' || value instanceof Date)
|
||||||
|
return value;
|
||||||
|
if (Array.isArray(value)) return value.map((part) => filter(name, part));
|
||||||
|
const properties = entityDefinitions[name].properties;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, item]: [string, any]) => {
|
||||||
|
if (item === undefined) throw new Error('Undefined database predicate');
|
||||||
|
// LIKE searches JSON's serialized text, matching the pre-migration behavior.
|
||||||
|
if (
|
||||||
|
key in properties &&
|
||||||
|
(properties as any)[key].type === 'json' &&
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
'$like' in item
|
||||||
|
)
|
||||||
|
return [raw(`cast("${key}" as text)`), item];
|
||||||
|
return [key, filter(name, item)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function values(
|
||||||
|
name: keyof typeof entityDefinitions,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
insert = false,
|
||||||
|
) {
|
||||||
|
const properties = entityDefinitions[name].properties;
|
||||||
|
const data = Object.fromEntries(
|
||||||
|
Object.entries(payload).filter(
|
||||||
|
([key, value]) => key in properties && value !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if ((properties as any)[key].type === 'json' && value !== null)
|
||||||
|
data[key] = JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
if (insert && !data.createdAt) data.createdAt = now;
|
||||||
|
data.updatedAt = now;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plain(name: keyof typeof entityDefinitions, row: any) {
|
||||||
|
if (!row) return null;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(entityDefinitions[name].properties)
|
||||||
|
.filter((key) => row[key] !== undefined)
|
||||||
|
.map((key) => [key, row[key]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(
|
||||||
|
operation: string,
|
||||||
|
args: any[],
|
||||||
|
token?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
await ready;
|
||||||
|
// Reject queued work after owner death; the monitor interrupts running SQL.
|
||||||
|
if (process.ppid !== workerData.ownerPid) process.exit(1);
|
||||||
|
const connection = orm.em.getConnection();
|
||||||
|
if (operation === 'begin') {
|
||||||
|
await connection.execute(
|
||||||
|
transactions.length ? `SAVEPOINT "ql_${token}"` : 'BEGIN IMMEDIATE',
|
||||||
|
);
|
||||||
|
transactions.push(token!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token !== transactions[transactions.length - 1])
|
||||||
|
throw new Error('Database transaction ownership mismatch');
|
||||||
|
if (operation === 'commit' || operation === 'rollback') {
|
||||||
|
if (!transactions.length) throw new Error('No active database transaction');
|
||||||
|
const nested = transactions.length > 1;
|
||||||
|
if (operation === 'rollback' && nested)
|
||||||
|
await connection.execute(`ROLLBACK TO SAVEPOINT "ql_${token}"`);
|
||||||
|
await connection.execute(
|
||||||
|
nested ? `RELEASE SAVEPOINT "ql_${token}"` : operation.toUpperCase(),
|
||||||
|
);
|
||||||
|
transactions.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (operation === 'close') {
|
||||||
|
if (transactions.length)
|
||||||
|
throw new Error('Cannot close an active transaction');
|
||||||
|
cache?.close();
|
||||||
|
await orm.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (operation === 'query') return connection.execute(args[0], args[1]);
|
||||||
|
if (operation === 'cache') {
|
||||||
|
if (!workerData.cacheStorage)
|
||||||
|
throw new Error('No cache database configured');
|
||||||
|
if (!cache) {
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
mkdirSync(dirname(workerData.cacheStorage), { recursive: true });
|
||||||
|
cache = new DatabaseSync(workerData.cacheStorage);
|
||||||
|
cache.exec(
|
||||||
|
'PRAGMA busy_timeout=10000;CREATE TABLE IF NOT EXISTS keyv(key VARCHAR(255) PRIMARY KEY,value TEXT)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [action, key, value] = args;
|
||||||
|
switch (action) {
|
||||||
|
case 'get':
|
||||||
|
return cache.prepare('SELECT value FROM keyv WHERE key=?').get(key)
|
||||||
|
?.value;
|
||||||
|
case 'set':
|
||||||
|
cache
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO keyv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
|
||||||
|
)
|
||||||
|
.run(key, value);
|
||||||
|
return true;
|
||||||
|
case 'delete':
|
||||||
|
return (
|
||||||
|
cache.prepare('DELETE FROM keyv WHERE key=?').run(key).changes > 0
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported cache operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [name, condition, options = {}] = args as [
|
||||||
|
keyof typeof entityDefinitions,
|
||||||
|
any,
|
||||||
|
any,
|
||||||
|
];
|
||||||
|
if (!(name in entityDefinitions)) throw new Error('Unknown entity');
|
||||||
|
const em = orm.em.fork();
|
||||||
|
const entityName = entities.find((entity) => entity.meta.className === name)!;
|
||||||
|
if (operation !== 'insert' && condition == null)
|
||||||
|
throw new Error('Missing database predicate');
|
||||||
|
const query = operation === 'insert' ? {} : filter(name, condition);
|
||||||
|
const readOptions = {
|
||||||
|
...options,
|
||||||
|
orderBy: options.orderBy?.map((order: Record<string, string>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(order).map(([field, direction]) => [
|
||||||
|
name === 'Env' && field === 'isPinned'
|
||||||
|
? raw('COALESCE("isPinned",0)')
|
||||||
|
: field,
|
||||||
|
direction,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
switch (operation) {
|
||||||
|
case 'find':
|
||||||
|
return (await em.find(entityName, query, readOptions)).map((row) =>
|
||||||
|
plain(name, row),
|
||||||
|
);
|
||||||
|
case 'findOne':
|
||||||
|
return plain(name, await em.findOne(entityName, query, readOptions));
|
||||||
|
case 'count':
|
||||||
|
return em.count(entityName, query);
|
||||||
|
case 'insert': {
|
||||||
|
const id = await em.insert(entityName, values(name, condition, true));
|
||||||
|
return plain(name, await em.findOneOrFail(entityName, { id }));
|
||||||
|
}
|
||||||
|
case 'nativeUpdate':
|
||||||
|
return em.nativeUpdate(entityName, query, values(name, options));
|
||||||
|
case 'nativeDelete':
|
||||||
|
return em.nativeDelete(entityName, query);
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported database operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDatabaseWorker(settings: typeof workerData) {
|
||||||
|
workerData = settings;
|
||||||
|
ready = initialize();
|
||||||
|
void ready.catch(() => {});
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
process.on('message', ({ id, operation, args, token }) => {
|
||||||
|
// Continue after a failed statement so the owner can still roll back.
|
||||||
|
queue = queue.then(async () => {
|
||||||
|
try {
|
||||||
|
process.send?.({
|
||||||
|
id,
|
||||||
|
value: await execute(operation, args, token),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
const code =
|
||||||
|
error.name === 'UniqueConstraintViolationException'
|
||||||
|
? 'UNIQUE_CONSTRAINT'
|
||||||
|
: error.name === 'TableNotFoundException'
|
||||||
|
? 'DATABASE_SCHEMA_MISSING'
|
||||||
|
: (
|
||||||
|
{
|
||||||
|
5: 'SQLITE_BUSY',
|
||||||
|
6: 'SQLITE_LOCKED',
|
||||||
|
8: 'SQLITE_READONLY',
|
||||||
|
10: 'SQLITE_IOERR',
|
||||||
|
11: 'SQLITE_CORRUPT',
|
||||||
|
13: 'SQLITE_FULL',
|
||||||
|
14: 'SQLITE_CANTOPEN',
|
||||||
|
26: 'SQLITE_NOTADB',
|
||||||
|
} as Record<number, string>
|
||||||
|
)[Number(error.errcode) & 255] || 'DATABASE_QUERY_FAILED';
|
||||||
|
process.send?.({
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message:
|
||||||
|
code === 'UNIQUE_CONSTRAINT'
|
||||||
|
? 'Resource already exists'
|
||||||
|
: `Database operation failed (${code})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-16
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
|
|
||||||
export class Dependence {
|
export class Dependence {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
@@ -47,17 +46,5 @@ export enum versionDependenceCommandTypes {
|
|||||||
'=',
|
'=',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DependenceInstance
|
export type DependenceInstance = RecordRow<Dependence>;
|
||||||
extends Model<Dependence, Dependence>,
|
export const DependenceModel = new Repository<Dependence>('Dependence');
|
||||||
Dependence {}
|
|
||||||
export const DependenceModel = sequelize.define<DependenceInstance>(
|
|
||||||
'Dependence',
|
|
||||||
{
|
|
||||||
name: DataTypes.STRING,
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
timestamp: DataTypes.STRING,
|
|
||||||
status: DataTypes.NUMBER,
|
|
||||||
log: DataTypes.JSON,
|
|
||||||
remark: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
+3
-13
@@ -1,5 +1,4 @@
|
|||||||
import { DataTypes, Model } from 'sequelize';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { sequelize } from '.';
|
|
||||||
|
|
||||||
export class Env {
|
export class Env {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -38,14 +37,5 @@ export const initPosition = 4500000000000000;
|
|||||||
export const stepPosition = 10000000000;
|
export const stepPosition = 10000000000;
|
||||||
export const minPosition = 100;
|
export const minPosition = 100;
|
||||||
|
|
||||||
export interface EnvInstance extends Model<Env, Env>, Env {}
|
export type EnvInstance = RecordRow<Env>;
|
||||||
export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
export const EnvModel = new Repository<Env>('Env');
|
||||||
value: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
|
||||||
timestamp: DataTypes.STRING,
|
|
||||||
status: DataTypes.NUMBER,
|
|
||||||
position: DataTypes.NUMBER,
|
|
||||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
|
||||||
remarks: DataTypes.STRING,
|
|
||||||
isPinned: DataTypes.NUMBER,
|
|
||||||
labels: DataTypes.JSON,
|
|
||||||
});
|
|
||||||
|
|||||||
+5
-18
@@ -1,23 +1,10 @@
|
|||||||
import { Sequelize, Transaction } from 'sequelize';
|
|
||||||
import config from '../config/index';
|
import config from '../config/index';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { DatabaseClient } from './databaseClient';
|
||||||
|
|
||||||
export const sequelize = new Sequelize({
|
export const database = new DatabaseClient(
|
||||||
dialect: 'sqlite',
|
join(config.dbPath, 'database.sqlite'),
|
||||||
storage: join(config.dbPath, 'database.sqlite'),
|
join(config.dbPath, 'keyv.sqlite'),
|
||||||
logging: false,
|
);
|
||||||
retry: {
|
|
||||||
max: 10,
|
|
||||||
match: ['SQLITE_BUSY: database is locked'],
|
|
||||||
},
|
|
||||||
pool: {
|
|
||||||
max: 5,
|
|
||||||
min: 2,
|
|
||||||
idle: 30000,
|
|
||||||
acquire: 30000,
|
|
||||||
evict: 10000,
|
|
||||||
},
|
|
||||||
transactionType: Transaction.TYPES.IMMEDIATE,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ResponseType<T> = { code: number; data?: T; message?: string };
|
export type ResponseType<T> = { code: number; data?: T; message?: string };
|
||||||
|
|||||||
+9
-1
@@ -21,6 +21,7 @@ export enum NotificationMode {
|
|||||||
'ntfy' = 'ntfy',
|
'ntfy' = 'ntfy',
|
||||||
'wxPusherBot' = 'wxPusherBot',
|
'wxPusherBot' = 'wxPusherBot',
|
||||||
'openiLink' = 'openiLink',
|
'openiLink' = 'openiLink',
|
||||||
|
'wpush' = 'wpush',
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class NotificationBaseInfo {
|
abstract class NotificationBaseInfo {
|
||||||
@@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo {
|
|||||||
public openiLinkContextToken = '';
|
public openiLinkContextToken = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WpushNotification extends NotificationBaseInfo {
|
||||||
|
public wpushApiKey = '';
|
||||||
|
public wpushChannel = '';
|
||||||
|
public wpushTopicCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationInfo
|
export interface NotificationInfo
|
||||||
extends GoCqHttpBotNotification,
|
extends GoCqHttpBotNotification,
|
||||||
GotifyNotification,
|
GotifyNotification,
|
||||||
@@ -195,4 +202,5 @@ export interface NotificationInfo
|
|||||||
NtfyNotification,
|
NtfyNotification,
|
||||||
WxPusherBotNotification,
|
WxPusherBotNotification,
|
||||||
WxPusherSptNotification,
|
WxPusherSptNotification,
|
||||||
OpeniLinkNotification {}
|
OpeniLinkNotification,
|
||||||
|
WpushNotification {}
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,13 +23,14 @@ export interface AppToken {
|
|||||||
expiration: number;
|
expiration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
|
export type AppScope =
|
||||||
|
| 'envs'
|
||||||
|
| 'crons'
|
||||||
|
| 'configs'
|
||||||
|
| 'scripts'
|
||||||
|
| 'logs'
|
||||||
|
| 'system'
|
||||||
|
| 'dashboard';
|
||||||
|
|
||||||
export interface AppInstance extends Model<App, App>, App {}
|
export type AppInstance = RecordRow<App>;
|
||||||
export const AppModel = sequelize.define<AppInstance>('App', {
|
export const AppModel = new Repository<App>('App');
|
||||||
name: { type: DataTypes.STRING, unique: 'name' },
|
|
||||||
scopes: DataTypes.JSON,
|
|
||||||
client_id: DataTypes.STRING,
|
|
||||||
client_secret: DataTypes.STRING,
|
|
||||||
tokens: DataTypes.JSON,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type Query = Record<string, any>;
|
||||||
|
export type QuerySpec = {
|
||||||
|
where: Query;
|
||||||
|
orderBy?: Record<string, string>[];
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function orderBy(order: string[][]): Record<string, string>[] {
|
||||||
|
return order.map(([field, direction]) => {
|
||||||
|
if (!/^(asc|desc)$/i.test(direction))
|
||||||
|
throw new Error('Invalid sort direction');
|
||||||
|
return { [field]: direction.toLowerCase() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textSearch(fields: string[], text: string): Query {
|
||||||
|
return {
|
||||||
|
$or: fields.flatMap((field) =>
|
||||||
|
[...new Set([text, encodeURI(text)])].map((value) => ({
|
||||||
|
[field]: { $like: `%${value}%` },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { database } from '.';
|
||||||
|
import { DatabaseClient } from './databaseClient';
|
||||||
|
|
||||||
|
export type RecordRow<T> = T & { id: number; createdAt: Date; updatedAt: Date };
|
||||||
|
export type Query = Record<string, any>;
|
||||||
|
export type ReadOptions = {
|
||||||
|
fields?: string[];
|
||||||
|
orderBy?: Record<string, string>[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A small IPC boundary with MikroORM's native query/operation names. No ORM
|
||||||
|
// entities or request identity maps escape the single database worker.
|
||||||
|
export class Repository<T> {
|
||||||
|
constructor(
|
||||||
|
private name: string,
|
||||||
|
private client: () => DatabaseClient = () => database,
|
||||||
|
) {}
|
||||||
|
find(where: Query = {}, options: ReadOptions = {}): Promise<RecordRow<T>[]> {
|
||||||
|
return this.client().request('find', this.name, where, options);
|
||||||
|
}
|
||||||
|
findOne(
|
||||||
|
where: Query,
|
||||||
|
options: ReadOptions = {},
|
||||||
|
): Promise<RecordRow<T> | null> {
|
||||||
|
return this.client().request('findOne', this.name, where, options);
|
||||||
|
}
|
||||||
|
count(where: Query = {}): Promise<number> {
|
||||||
|
return this.client().request('count', this.name, where);
|
||||||
|
}
|
||||||
|
insert(data: Partial<T>): Promise<RecordRow<T>> {
|
||||||
|
return this.client().request('insert', this.name, data);
|
||||||
|
}
|
||||||
|
nativeUpdate(where: Query, data: Partial<T>): Promise<number> {
|
||||||
|
return this.client().request('nativeUpdate', this.name, where, data);
|
||||||
|
}
|
||||||
|
nativeDelete(where: Query): Promise<number> {
|
||||||
|
return this.client().request('nativeDelete', this.name, where);
|
||||||
|
}
|
||||||
|
async insertMany(data: Partial<T>[]): Promise<RecordRow<T>[]> {
|
||||||
|
return this.client().transaction(async () => {
|
||||||
|
const rows = [];
|
||||||
|
for (const item of data) rows.push(await this.insert(item));
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getOrCreate(where: Query): Promise<RecordRow<T>> {
|
||||||
|
return this.client().transaction(
|
||||||
|
async () =>
|
||||||
|
(await this.findOne(where)) || this.insert(where as Partial<T>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model } from 'sequelize';
|
|
||||||
|
|
||||||
export enum InstanceStatus {
|
export enum InstanceStatus {
|
||||||
'running' = 0,
|
'running' = 0,
|
||||||
@@ -11,8 +10,6 @@ export enum InstanceStatus {
|
|||||||
export interface RunningInstanceAttributes {
|
export interface RunningInstanceAttributes {
|
||||||
id?: number;
|
id?: number;
|
||||||
cron_id: number;
|
cron_id: number;
|
||||||
run_id?: string | null;
|
|
||||||
attempt_id?: string | null;
|
|
||||||
pid?: number;
|
pid?: number;
|
||||||
log_path?: string;
|
log_path?: string;
|
||||||
started_at: number;
|
started_at: number;
|
||||||
@@ -24,8 +21,6 @@ export interface RunningInstanceAttributes {
|
|||||||
export class RunningInstance {
|
export class RunningInstance {
|
||||||
id?: number;
|
id?: number;
|
||||||
cron_id!: number;
|
cron_id!: number;
|
||||||
run_id?: string | null;
|
|
||||||
attempt_id?: string | null;
|
|
||||||
pid?: number;
|
pid?: number;
|
||||||
log_path?: string;
|
log_path?: string;
|
||||||
started_at!: number;
|
started_at!: number;
|
||||||
@@ -36,8 +31,6 @@ export class RunningInstance {
|
|||||||
constructor(options: RunningInstanceAttributes) {
|
constructor(options: RunningInstanceAttributes) {
|
||||||
this.id = options.id;
|
this.id = options.id;
|
||||||
this.cron_id = options.cron_id;
|
this.cron_id = options.cron_id;
|
||||||
this.run_id = options.run_id;
|
|
||||||
this.attempt_id = options.attempt_id;
|
|
||||||
this.pid = options.pid;
|
this.pid = options.pid;
|
||||||
this.log_path = options.log_path;
|
this.log_path = options.log_path;
|
||||||
this.started_at = options.started_at;
|
this.started_at = options.started_at;
|
||||||
@@ -47,50 +40,7 @@ export class RunningInstance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunningInstanceModel
|
export type RunningInstanceModel = RecordRow<RunningInstanceAttributes>;
|
||||||
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
|
export const RunningInstanceModel = new Repository<RunningInstanceAttributes>(
|
||||||
RunningInstanceAttributes {}
|
|
||||||
|
|
||||||
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
|
|
||||||
'RunningInstance',
|
'RunningInstance',
|
||||||
{
|
|
||||||
cron_id: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: true,
|
|
||||||
unique: 'running_instances_attempt_uidx',
|
|
||||||
},
|
|
||||||
pid: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
log_path: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
started_at: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
finished_at: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: InstanceStatus.running,
|
|
||||||
},
|
|
||||||
exit_code: {
|
|
||||||
type: DataTypes.NUMBER,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Persisted SQLite names and types are part of the rollback contract.
|
||||||
|
type Field = {
|
||||||
|
type: string;
|
||||||
|
fieldName: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
primary?: boolean;
|
||||||
|
autoincrement?: boolean;
|
||||||
|
};
|
||||||
|
function entity<Name extends string>(
|
||||||
|
name: Name,
|
||||||
|
tableName: string,
|
||||||
|
groups: Record<string, string[]>,
|
||||||
|
required: string[] = [],
|
||||||
|
) {
|
||||||
|
const properties: Record<string, Field> = {
|
||||||
|
id: { type: 'number', fieldName: 'id', primary: true, autoincrement: true },
|
||||||
|
};
|
||||||
|
for (const [type, fields] of Object.entries(groups))
|
||||||
|
for (const field of fields)
|
||||||
|
properties[field] = {
|
||||||
|
type,
|
||||||
|
fieldName: field,
|
||||||
|
nullable: !required.includes(field),
|
||||||
|
};
|
||||||
|
for (const field of ['createdAt', 'updatedAt'])
|
||||||
|
properties[field] = {
|
||||||
|
type: 'legacyDate',
|
||||||
|
fieldName: field,
|
||||||
|
nullable: false,
|
||||||
|
};
|
||||||
|
return { name, tableName, properties };
|
||||||
|
}
|
||||||
|
export const entityDefinitions = {
|
||||||
|
App: entity(
|
||||||
|
'App',
|
||||||
|
'Apps',
|
||||||
|
{
|
||||||
|
string: ['name', 'client_id', 'client_secret'],
|
||||||
|
json: ['scopes', 'tokens'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
Auth: entity('Auth', 'Auths', { string: ['ip', 'type'], json: ['info'] }, []),
|
||||||
|
CrontabStat: entity(
|
||||||
|
'CrontabStat',
|
||||||
|
'CrontabStats',
|
||||||
|
{
|
||||||
|
number: [
|
||||||
|
'ref_id',
|
||||||
|
'run_count',
|
||||||
|
'success_count',
|
||||||
|
'fail_count',
|
||||||
|
'total_time',
|
||||||
|
'max_time',
|
||||||
|
],
|
||||||
|
string: ['date'],
|
||||||
|
},
|
||||||
|
['ref_id', 'date'],
|
||||||
|
),
|
||||||
|
CrontabView: entity(
|
||||||
|
'CrontabView',
|
||||||
|
'CrontabViews',
|
||||||
|
{
|
||||||
|
string: ['name', 'filterRelation'],
|
||||||
|
number: ['position', 'isDisabled', 'type'],
|
||||||
|
json: ['filters', 'sorts'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
Crontab: entity(
|
||||||
|
'Crontab',
|
||||||
|
'Crontabs',
|
||||||
|
{
|
||||||
|
string: [
|
||||||
|
'name',
|
||||||
|
'command',
|
||||||
|
'schedule',
|
||||||
|
'timestamp',
|
||||||
|
'log_path',
|
||||||
|
'queued_token',
|
||||||
|
'task_before',
|
||||||
|
'task_after',
|
||||||
|
'log_name',
|
||||||
|
'work_dir',
|
||||||
|
],
|
||||||
|
boolean: ['saved'],
|
||||||
|
number: [
|
||||||
|
'status',
|
||||||
|
'isSystem',
|
||||||
|
'pid',
|
||||||
|
'isDisabled',
|
||||||
|
'isPinned',
|
||||||
|
'last_running_time',
|
||||||
|
'last_execution_time',
|
||||||
|
'sub_id',
|
||||||
|
'allow_multiple_instances',
|
||||||
|
],
|
||||||
|
json: ['labels', 'extra_schedules'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
Dependence: entity(
|
||||||
|
'Dependence',
|
||||||
|
'Dependences',
|
||||||
|
{
|
||||||
|
string: ['name', 'timestamp', 'remark'],
|
||||||
|
number: ['type', 'status'],
|
||||||
|
json: ['log'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
Env: entity(
|
||||||
|
'Env',
|
||||||
|
'Envs',
|
||||||
|
{
|
||||||
|
string: ['value', 'timestamp', 'name', 'remarks'],
|
||||||
|
number: ['status', 'position', 'isPinned'],
|
||||||
|
json: ['labels'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
RunningInstance: entity(
|
||||||
|
'RunningInstance',
|
||||||
|
'RunningInstances',
|
||||||
|
{
|
||||||
|
number: [
|
||||||
|
'cron_id',
|
||||||
|
'pid',
|
||||||
|
'started_at',
|
||||||
|
'finished_at',
|
||||||
|
'status',
|
||||||
|
'exit_code',
|
||||||
|
],
|
||||||
|
string: ['log_path'],
|
||||||
|
},
|
||||||
|
['cron_id', 'started_at', 'status'],
|
||||||
|
),
|
||||||
|
Subscription: entity(
|
||||||
|
'Subscription',
|
||||||
|
'Subscriptions',
|
||||||
|
{
|
||||||
|
string: [
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'schedule',
|
||||||
|
'type',
|
||||||
|
'whitelist',
|
||||||
|
'blacklist',
|
||||||
|
'dependences',
|
||||||
|
'extensions',
|
||||||
|
'sub_before',
|
||||||
|
'sub_after',
|
||||||
|
'branch',
|
||||||
|
'pull_type',
|
||||||
|
'log_path',
|
||||||
|
'schedule_type',
|
||||||
|
'alias',
|
||||||
|
'proxy',
|
||||||
|
],
|
||||||
|
json: ['interval_schedule', 'pull_option'],
|
||||||
|
number: ['status', 'pid', 'is_disabled', 'autoAddCron', 'autoDelCron'],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
export const initialSchema = [
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Apps` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `scopes` JSON, `client_id` VARCHAR(255), `client_secret` VARCHAR(255), `tokens` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Auths` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ip` VARCHAR(255), `type` VARCHAR(255), `info` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `CrontabStats` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ref_id` NUMBER NOT NULL, `date` VARCHAR(255) NOT NULL, `run_count` NUMBER DEFAULT 0, `success_count` NUMBER DEFAULT 0, `fail_count` NUMBER DEFAULT 0, `total_time` NUMBER DEFAULT 0, `max_time` NUMBER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `CrontabViews` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `position` NUMBER, `isDisabled` NUMBER, `filters` JSON, `sorts` JSON, `filterRelation` VARCHAR(255), `type` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Crontabs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `command` VARCHAR(255), `schedule` VARCHAR(255), `timestamp` VARCHAR(255), `saved` TINYINT(1), `status` NUMBER, `isSystem` NUMBER, `pid` NUMBER, `isDisabled` NUMBER, `isPinned` NUMBER, `log_path` VARCHAR(255), `queued_token` VARCHAR(255), `labels` JSON, `last_running_time` NUMBER, `last_execution_time` NUMBER, `sub_id` NUMBER, `extra_schedules` JSON, `task_before` VARCHAR(255), `task_after` VARCHAR(255), `log_name` VARCHAR(255), `allow_multiple_instances` NUMBER, `work_dir` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `command`, `schedule`))',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Dependences` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `type` NUMBER, `timestamp` VARCHAR(255), `status` NUMBER, `log` JSON, `remark` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Envs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `value` VARCHAR(255), `timestamp` VARCHAR(255), `status` NUMBER, `position` NUMBER, `name` VARCHAR(255), `remarks` VARCHAR(255), `isPinned` NUMBER, `labels` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`value`, `name`))',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `RunningInstances` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `cron_id` NUMBER NOT NULL, `pid` NUMBER, `log_path` VARCHAR(255), `started_at` NUMBER NOT NULL, `finished_at` NUMBER, `status` NUMBER NOT NULL DEFAULT 0, `exit_code` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
|
||||||
|
'CREATE TABLE IF NOT EXISTS `Subscriptions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `url` VARCHAR(255), `schedule` VARCHAR(255), `interval_schedule` JSON, `type` VARCHAR(255), `whitelist` VARCHAR(255), `blacklist` VARCHAR(255), `status` NUMBER, `dependences` VARCHAR(255), `extensions` VARCHAR(255), `sub_before` VARCHAR(255), `sub_after` VARCHAR(255), `branch` VARCHAR(255), `pull_type` VARCHAR(255), `pull_option` JSON, `pid` NUMBER, `is_disabled` NUMBER, `log_path` VARCHAR(255), `schedule_type` VARCHAR(255), `alias` VARCHAR(255), `proxy` VARCHAR(255), `autoAddCron` NUMBER, `autoDelCron` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `url`, `schedule`, `interval_schedule`), UNIQUE (`alias`))',
|
||||||
|
'CREATE INDEX IF NOT EXISTS `crontab_stats_date` ON `CrontabStats` (`date`)',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS `crontab_stats_ref_id_date` ON `CrontabStats` (`ref_id`, `date`)',
|
||||||
|
];
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { DataTypes, Model, ModelStatic, Sequelize } from 'sequelize';
|
|
||||||
import { sequelize } from '.';
|
|
||||||
|
|
||||||
export interface SchemaMigrationAttributes {
|
|
||||||
id: string;
|
|
||||||
checksum: string;
|
|
||||||
applied_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchemaMigrationInstance
|
|
||||||
extends Model<SchemaMigrationAttributes, SchemaMigrationAttributes>,
|
|
||||||
SchemaMigrationAttributes {}
|
|
||||||
|
|
||||||
export function defineSchemaMigrationModel(
|
|
||||||
database: Sequelize,
|
|
||||||
): ModelStatic<SchemaMigrationInstance> {
|
|
||||||
return database.define<SchemaMigrationInstance>(
|
|
||||||
'SchemaMigration',
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
checksum: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
applied_at: {
|
|
||||||
type: DataTypes.BIGINT,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tableName: 'SchemaMigrations',
|
|
||||||
timestamps: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SchemaMigrationModel = defineSchemaMigrationModel(sequelize);
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { database } from '.';
|
||||||
|
|
||||||
|
// Aggregate SQL stays explicit and bound; public input is never used as an SQL
|
||||||
|
// identifier. These queries preserve the dashboard response names.
|
||||||
|
export const statistics = {
|
||||||
|
async totals(date: string) {
|
||||||
|
const rows = await database.query(
|
||||||
|
`SELECT SUM(run_count) AS total_runs,
|
||||||
|
SUM(success_count) AS total_success, SUM(fail_count) AS total_fail,
|
||||||
|
SUM(total_time) AS total_time FROM CrontabStats WHERE date=?`,
|
||||||
|
[date],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
},
|
||||||
|
results(date: string, column: 'success_count' | 'fail_count') {
|
||||||
|
if (!['success_count', 'fail_count'].includes(column))
|
||||||
|
throw new Error('Invalid statistic');
|
||||||
|
return database.query(
|
||||||
|
`SELECT ref_id,SUM(${column}) AS result_count FROM CrontabStats
|
||||||
|
WHERE date=? AND ${column}>0 GROUP BY ref_id ORDER BY result_count DESC,ref_id ASC`,
|
||||||
|
[date],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
trend(dates: string[]) {
|
||||||
|
if (!dates.length) return Promise.resolve([]);
|
||||||
|
return database.query(
|
||||||
|
`SELECT date,SUM(run_count) AS total_runs,
|
||||||
|
SUM(success_count) AS total_success,SUM(fail_count) AS total_fail FROM CrontabStats
|
||||||
|
WHERE date IN (${dates
|
||||||
|
.map(() => '?')
|
||||||
|
.join(',')}) GROUP BY date ORDER BY date ASC`,
|
||||||
|
dates,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ranking(date: string, metric: 'total_time' | 'run_count') {
|
||||||
|
if (!['total_time', 'run_count'].includes(metric))
|
||||||
|
throw new Error('Invalid statistic');
|
||||||
|
return database.query(
|
||||||
|
`SELECT ref_id,SUM(total_time) AS total_time,SUM(run_count) AS run_count,
|
||||||
|
SUM(success_count) AS success_count,MAX(max_time) AS max_time FROM CrontabStats
|
||||||
|
WHERE date=? AND run_count>0 GROUP BY ref_id ORDER BY SUM(${metric}) DESC LIMIT 5`,
|
||||||
|
[date],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||||
|
|
||||||
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
|
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
|
||||||
@@ -20,7 +19,7 @@ export class Subscription {
|
|||||||
pull_option?:
|
pull_option?:
|
||||||
| { private_key: string }
|
| { private_key: string }
|
||||||
| { username: string; password: string };
|
| { username: string; password: string };
|
||||||
pid?: number;
|
pid?: number | null;
|
||||||
is_disabled?: 1 | 0;
|
is_disabled?: 1 | 0;
|
||||||
log_path?: string;
|
log_path?: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -70,46 +69,5 @@ export enum SubscriptionStatus {
|
|||||||
'queued',
|
'queued',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionInstance
|
export type SubscriptionInstance = RecordRow<Subscription>;
|
||||||
extends Model<Subscription, Subscription>,
|
export const SubscriptionModel = new Repository<Subscription>('Subscription');
|
||||||
Subscription {}
|
|
||||||
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
|
|
||||||
'Subscription',
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
schedule: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
},
|
|
||||||
interval_schedule: {
|
|
||||||
unique: 'compositeIndex',
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
},
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
whitelist: DataTypes.STRING,
|
|
||||||
blacklist: DataTypes.STRING,
|
|
||||||
status: DataTypes.NUMBER,
|
|
||||||
dependences: DataTypes.STRING,
|
|
||||||
extensions: DataTypes.STRING,
|
|
||||||
sub_before: DataTypes.STRING,
|
|
||||||
sub_after: DataTypes.STRING,
|
|
||||||
branch: DataTypes.STRING,
|
|
||||||
pull_type: DataTypes.STRING,
|
|
||||||
pull_option: DataTypes.JSON,
|
|
||||||
pid: DataTypes.NUMBER,
|
|
||||||
is_disabled: DataTypes.NUMBER,
|
|
||||||
log_path: DataTypes.STRING,
|
|
||||||
schedule_type: DataTypes.STRING,
|
|
||||||
alias: { type: DataTypes.STRING, unique: 'alias' },
|
|
||||||
proxy: { type: DataTypes.STRING, allowNull: true },
|
|
||||||
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
|
|
||||||
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
+24
-13
@@ -1,5 +1,4 @@
|
|||||||
import { sequelize } from '.';
|
import { Repository, RecordRow } from './repository';
|
||||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
|
||||||
import { NotificationInfo } from './notify';
|
import { NotificationInfo } from './notify';
|
||||||
|
|
||||||
export class SystemInfo {
|
export class SystemInfo {
|
||||||
@@ -28,8 +27,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;
|
||||||
@@ -41,6 +44,8 @@ export interface SystemConfigInfo {
|
|||||||
linuxMirror?: string;
|
linuxMirror?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
globalSshKey?: string;
|
globalSshKey?: string;
|
||||||
|
runningInstanceRetentionDays?: number;
|
||||||
|
cronStatRetentionDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginLogInfo {
|
export interface LoginLogInfo {
|
||||||
@@ -51,6 +56,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;
|
||||||
@@ -73,26 +89,21 @@ export interface AuthInfo {
|
|||||||
lastaddr: string;
|
lastaddr: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
isTwoFactorChecking: boolean;
|
isTwoFactorChecking: boolean;
|
||||||
|
twoFactorExpiresAt?: number;
|
||||||
|
lastTwoFactorStep?: number;
|
||||||
token: string;
|
token: string;
|
||||||
tokens: Record<string, string | TokenInfo[]>;
|
tokens: Record<string, string | TokenInfo[]>;
|
||||||
twoFactorActivated: boolean;
|
twoFactorActivated: boolean;
|
||||||
twoFactorSecret: string;
|
twoFactorSecret: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
blockedIps?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SystemModelInfo = SystemConfigInfo &
|
export type SystemModelInfo = SystemConfigInfo &
|
||||||
Partial<NotificationInfo> &
|
Partial<NotificationInfo> &
|
||||||
LoginLogInfo &
|
LoginLogInfo &
|
||||||
|
Partial<NotifyLogInfo> &
|
||||||
Partial<AuthInfo>;
|
Partial<AuthInfo>;
|
||||||
|
|
||||||
export interface SystemInstance
|
export type SystemInstance = RecordRow<SystemInfo>;
|
||||||
extends Model<SystemInfo, SystemInfo>,
|
export const SystemModel = new Repository<SystemInfo>('Auth');
|
||||||
SystemInfo {}
|
|
||||||
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
|
|
||||||
ip: DataTypes.STRING,
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
info: {
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "node20",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"rootDir": "..",
|
||||||
|
"outDir": "../../static/build"
|
||||||
|
},
|
||||||
|
"include": ["./databaseWorker.ts", "./schema.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Application } from 'express';
|
|||||||
import linkDeps from './deps';
|
import linkDeps from './deps';
|
||||||
import initTask from './initTask';
|
import initTask from './initTask';
|
||||||
import initFile from './initFile';
|
import initFile from './initFile';
|
||||||
|
import { initializeTrustProxy } from '../shared/trustProxy';
|
||||||
|
|
||||||
export default async ({ app }: { app: Application }) => {
|
export default async ({ app }: { app: Application }) => {
|
||||||
depInjectorLoader();
|
depInjectorLoader();
|
||||||
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
|
|||||||
Logger.info('[boot] Init task loaded');
|
Logger.info('[boot] Init task loaded');
|
||||||
|
|
||||||
expressLoader({ app });
|
expressLoader({ app });
|
||||||
|
await initializeTrustProxy(app);
|
||||||
Logger.info('[boot] Express loaded');
|
Logger.info('[boot] Express loaded');
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-21
@@ -1,28 +1,14 @@
|
|||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import { EnvModel } from '../data/env';
|
import { database } from '../data';
|
||||||
import { CrontabModel } from '../data/cron';
|
import { initialSchema } from '../data/schema';
|
||||||
import { DependenceModel } from '../data/dependence';
|
import { migrateSchema } from '../shared/schemaMigrations';
|
||||||
import { AppModel } from '../data/open';
|
|
||||||
import { SystemModel } from '../data/system';
|
|
||||||
import { SubscriptionModel } from '../data/subscription';
|
|
||||||
import { CrontabViewModel } from '../data/cronView';
|
|
||||||
import { CrontabStatModel } from '../data/cronStats';
|
|
||||||
import { RunningInstanceModel } from '../data/runningInstance';
|
|
||||||
import { runMigrations } from '../migrations/runner';
|
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
try {
|
try {
|
||||||
await CrontabModel.sync();
|
await database.transaction(async () => {
|
||||||
await DependenceModel.sync();
|
for (const sql of initialSchema) await database.query(sql);
|
||||||
await AppModel.sync();
|
await migrateSchema(database);
|
||||||
await SystemModel.sync();
|
});
|
||||||
await EnvModel.sync();
|
|
||||||
await SubscriptionModel.sync();
|
|
||||||
await CrontabViewModel.sync();
|
|
||||||
await CrontabStatModel.sync();
|
|
||||||
await RunningInstanceModel.sync();
|
|
||||||
await runMigrations();
|
|
||||||
|
|
||||||
Logger.info('[boot] DB loaded');
|
Logger.info('[boot] DB loaded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('[boot] DB load failed', error);
|
Logger.error('[boot] DB load failed', error);
|
||||||
|
|||||||
+45
-1
@@ -17,6 +17,50 @@ async function linkCommand() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function linkCommandToDir(commandDir: string) {
|
async function linkCommandToDir(commandDir: string) {
|
||||||
|
const cliRoot = process.env.QL_CLI_ROOT;
|
||||||
|
if (cliRoot) {
|
||||||
|
if (!path.isAbsolute(cliRoot)) {
|
||||||
|
throw new Error('QL_CLI_ROOT must be an absolute CLI installation path');
|
||||||
|
}
|
||||||
|
const { installCliEntrypoints } = require(path.join(
|
||||||
|
cliRoot,
|
||||||
|
'dist/local/entrypoints.js',
|
||||||
|
));
|
||||||
|
await installCliEntrypoints(commandDir, cliRoot);
|
||||||
|
const { installCronEntrypoint } = require(path.join(
|
||||||
|
cliRoot,
|
||||||
|
'dist/local/cronEntrypoint.js',
|
||||||
|
));
|
||||||
|
await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, {
|
||||||
|
...process.env,
|
||||||
|
QL_DIR: config.rootPath,
|
||||||
|
QL_DATA_DIR: config.dataPath,
|
||||||
|
});
|
||||||
|
// Container-wide legacy links may precede ~/bin. Ensure panel children use
|
||||||
|
// the explicitly selected entries without changing system-wide links.
|
||||||
|
process.env.PATH = [
|
||||||
|
commandDir,
|
||||||
|
...(process.env.PATH?.split(path.delimiter) ?? []).filter(
|
||||||
|
(entry) => entry !== commandDir,
|
||||||
|
),
|
||||||
|
].join(path.delimiter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronBridge = path.join(commandDir, 'crontab');
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
(await fs.lstat(cronBridge)).isFile() &&
|
||||||
|
(await fs.readFile(cronBridge, 'utf8')).includes(
|
||||||
|
'// QingLong CLI crontab bridge',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await fs.unlink(cronBridge);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const linkShell = [
|
const linkShell = [
|
||||||
{
|
{
|
||||||
src: 'update.sh',
|
src: 'update.sh',
|
||||||
@@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) {
|
|||||||
if (stats) {
|
if (stats) {
|
||||||
await fs.unlink(tmpTarget);
|
await fs.unlink(tmpTarget);
|
||||||
}
|
}
|
||||||
} catch (error) { }
|
} catch (error) {}
|
||||||
|
|
||||||
await fs.symlink(source, tmpTarget);
|
await fs.symlink(source, tmpTarget);
|
||||||
await fs.rename(tmpTarget, target);
|
await fs.rename(tmpTarget, target);
|
||||||
|
|||||||
+134
-118
@@ -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,46 +14,60 @@ 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 { withAuthAvailability } from '../middlewares/authAvailability';
|
||||||
|
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||||
|
const setting = value?.trim();
|
||||||
|
if (!setting) {
|
||||||
|
return 'loopback';
|
||||||
|
}
|
||||||
|
if (setting === 'true' || setting === 'false') {
|
||||||
|
return setting === 'true';
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(setting)) {
|
||||||
|
return Number(setting);
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
export default ({ app }: { app: Application }) => {
|
export default ({ app }: { app: Application }) => {
|
||||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||||
app.set('case sensitive routing', true);
|
app.set('case sensitive routing', true);
|
||||||
app.set('strict routing', true);
|
app.set('strict routing', true);
|
||||||
app.set('trust proxy', 'loopback');
|
app.set('trust proxy', resolveTrustProxy());
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
// Security: Reject case variations under protected API namespaces before
|
||||||
// Security: Path normalization middleware to prevent case variation attacks
|
// authentication checks can interpret the request differently from routing.
|
||||||
app.use((req, res, next) => {
|
app.use(protectedPathCase);
|
||||||
const originalPath = req.path;
|
|
||||||
const normalizedPath = originalPath.toLowerCase();
|
|
||||||
|
|
||||||
// Block requests with case variations on protected paths
|
|
||||||
if (originalPath !== normalizedPath &&
|
|
||||||
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
|
||||||
return res.status(400).json({
|
|
||||||
code: 400,
|
|
||||||
message: 'Invalid path format'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rewrite URLs to strip baseUrl prefix if configured
|
// Rewrite URLs to strip baseUrl prefix if configured
|
||||||
// This allows the rest of the app to work without baseUrl awareness
|
// This allows the rest of the app to work without baseUrl awareness
|
||||||
if (config.baseUrl) {
|
if (config.baseUrl) {
|
||||||
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||||
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
app.use(
|
||||||
|
`${config.api.prefix}/static`,
|
||||||
|
express.static(config.uploadPath, {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
|
||||||
|
['/user/login', '/user/init', '/user/two-factor/login'].map(
|
||||||
|
(route) => `${prefix}${route}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
credentialPaths,
|
||||||
|
bodyParser.json({ limit: '16kb' }),
|
||||||
|
bodyParser.urlencoded({ limit: '16kb', extended: false }),
|
||||||
|
);
|
||||||
app.use(bodyParser.json({ limit: '50mb' }));
|
app.use(bodyParser.json({ limit: '50mb' }));
|
||||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||||
|
|
||||||
const frontendPath = path.join(config.rootPath, 'static/dist');
|
const frontendPath = path.join(config.rootPath, 'static/dist');
|
||||||
app.use(express.static(frontendPath));
|
app.use(express.static(frontendPath));
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
expressjwt({
|
expressjwt({
|
||||||
secret: config.jwt.secret,
|
secret: config.jwt.secret,
|
||||||
@@ -62,7 +76,6 @@ export default ({ app }: { app: Application }) => {
|
|||||||
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use((req: Request, res, next) => {
|
app.use((req: Request, res, next) => {
|
||||||
if (!req.headers) {
|
if (!req.headers) {
|
||||||
req.platform = 'desktop';
|
req.platform = 'desktop';
|
||||||
@@ -72,92 +85,92 @@ export default ({ app }: { app: Application }) => {
|
|||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
app.use(
|
||||||
app.use(async (req: Request, res, next) => {
|
withAuthAvailability(async (req: Request, res, next) => {
|
||||||
const pathLower = req.path.toLowerCase();
|
const pathLower = req.path.toLowerCase();
|
||||||
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerToken = getToken(req);
|
|
||||||
if (pathLower.startsWith('/open/')) {
|
|
||||||
const apps = await shareStore.getApps();
|
|
||||||
const doc = apps?.filter((x) =>
|
|
||||||
x.tokens?.find((y) => y.value === headerToken),
|
|
||||||
)?.[0];
|
|
||||||
if (doc && doc.tokens && doc.tokens.length > 0) {
|
|
||||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
|
||||||
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
|
||||||
const key = keyMatch && keyMatch[1];
|
|
||||||
|
|
||||||
if (!doc.scopes.includes(key as AppScope)) {
|
|
||||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
|
||||||
message: t('暂无权限'),
|
|
||||||
});
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
|
|
||||||
const err = new UnauthorizedError('invalid_token', {
|
|
||||||
message: t('Token 已失效'),
|
|
||||||
});
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
const headerToken = getToken(req);
|
||||||
|
if (pathLower.startsWith('/open/')) {
|
||||||
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
const apps = await shareStore.getApps();
|
||||||
if (
|
const doc = apps?.filter((x) =>
|
||||||
!headerToken &&
|
x.tokens?.find((y) => y.value === headerToken),
|
||||||
originPath &&
|
)?.[0];
|
||||||
config.apiWhiteList.includes(originPath)
|
if (doc && doc.tokens && doc.tokens.length > 0) {
|
||||||
) {
|
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||||
return next();
|
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||||
}
|
const key = keyMatch && keyMatch[1];
|
||||||
|
if (!doc.scopes.includes(key as AppScope)) {
|
||||||
const authInfo = await shareStore.getAuthInfo();
|
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
message: t('暂无权限'),
|
||||||
return next();
|
});
|
||||||
}
|
return next(err);
|
||||||
|
}
|
||||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
if (
|
||||||
const errorMessage = headerToken
|
!currentToken ||
|
||||||
? t('Token 已失效')
|
currentToken.expiration < Math.round(Date.now() / 1000)
|
||||||
: t('请先登录');
|
) {
|
||||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
const err = new UnauthorizedError('invalid_token', {
|
||||||
next(err);
|
message: t('Token 已失效'),
|
||||||
});
|
});
|
||||||
|
return next(err);
|
||||||
app.use(async (req, res, next) => {
|
}
|
||||||
const pathLower = req.path.toLowerCase();
|
return next();
|
||||||
if (
|
}
|
||||||
![
|
}
|
||||||
'/api/user/init',
|
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
||||||
'/api/user/notification/init',
|
if (
|
||||||
'/open/user/init',
|
!headerToken &&
|
||||||
'/open/user/notification/init',
|
originPath &&
|
||||||
].includes(pathLower)
|
config.apiWhiteList.includes(originPath)
|
||||||
) {
|
) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
const authInfo =
|
const authInfo = await shareStore.getAuthInfo();
|
||||||
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
if (
|
||||||
|
isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)
|
||||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
) {
|
||||||
|
return next();
|
||||||
if (isInitialized) {
|
}
|
||||||
return res.send({ code: 450, message: t('未知错误') });
|
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||||
} else {
|
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
||||||
return next();
|
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||||
}
|
next(err);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
withAuthAvailability(async (req, res, next) => {
|
||||||
|
const pathLower = req.path.toLowerCase();
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
'/api/user/init',
|
||||||
|
'/api/user/notification/init',
|
||||||
|
'/open/user/init',
|
||||||
|
'/open/user/notification/init',
|
||||||
|
].includes(pathLower)
|
||||||
|
) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||||
|
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||||
|
if (isInitialized) {
|
||||||
|
return res.send({ code: 450, message: t('未知错误') });
|
||||||
|
} else {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.use(rewrite('/open/*', '/api/$1'));
|
app.use(rewrite('/open/*', '/api/$1'));
|
||||||
app.use(config.api.prefix, routes());
|
app.use(config.api.prefix, routes());
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
app.get('*', (_, 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) {
|
||||||
@@ -167,12 +180,13 @@ export default ({ app }: { app: Application }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
app.use(validationErrors);
|
||||||
app.use(errors());
|
app.use(uniqueConstraintError);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
err: Error & { status: number },
|
err: Error & {
|
||||||
|
status: number;
|
||||||
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
@@ -186,15 +200,16 @@ export default ({ app }: { app: Application }) => {
|
|||||||
return next(err);
|
return next(err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
err: Error & { errors: any[] },
|
err: Error & {
|
||||||
|
errors: any[];
|
||||||
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) => {
|
) => {
|
||||||
if (err.name.includes('Sequelize')) {
|
if (err.name === 'DatabaseError') {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
.send({
|
.send({
|
||||||
@@ -207,10 +222,11 @@ export default ({ app }: { app: Application }) => {
|
|||||||
return next(err);
|
return next(err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
err: Error & { status: number },
|
err: Error & {
|
||||||
|
status: number;
|
||||||
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+83
-112
@@ -1,3 +1,5 @@
|
|||||||
|
import { textSearch } from '../data/queries';
|
||||||
|
import { Query, QuerySpec, orderBy } from '../data/queries';
|
||||||
import DependenceService from '../services/dependence';
|
import DependenceService from '../services/dependence';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
@@ -5,7 +7,6 @@ import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
|||||||
import CronService from '../services/cron';
|
import CronService from '../services/cron';
|
||||||
import EnvService from '../services/env';
|
import EnvService from '../services/env';
|
||||||
import { DependenceModel, DependenceStatus } from '../data/dependence';
|
import { DependenceModel, DependenceStatus } from '../data/dependence';
|
||||||
import { Op } from 'sequelize';
|
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { CrontabViewModel, CronViewType } from '../data/cronView';
|
import { CrontabViewModel, CronViewType } from '../data/cronView';
|
||||||
import { initPosition } from '../data/env';
|
import { initPosition } from '../data/env';
|
||||||
@@ -13,14 +14,13 @@ 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 { AppModel } from '../data/open';
|
import cronClient from '../schedule/client';
|
||||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||||
import { setLang, systemLang } from '../shared/i18n';
|
import { setLang, systemLang } from '../shared/i18n';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const cronService = Container.get(CronService);
|
const cronService = Container.get(CronService);
|
||||||
const envService = Container.get(EnvService);
|
const envService = Container.get(EnvService);
|
||||||
@@ -28,34 +28,14 @@ export default async () => {
|
|||||||
const systemService = Container.get(SystemService);
|
const systemService = Container.get(SystemService);
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
const openService = Container.get(OpenService);
|
const openService = Container.get(OpenService);
|
||||||
|
|
||||||
// 初始化增加系统配置
|
// 初始化增加系统配置
|
||||||
let systemApp = (
|
await openService.initializeSystemApp();
|
||||||
await AppModel.findOne({
|
const systemConfig = await SystemModel.getOrCreate({
|
||||||
where: { name: 'system' },
|
type: AuthDataType.systemConfig,
|
||||||
})
|
|
||||||
)?.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({
|
|
||||||
where: { type: AuthDataType.systemConfig },
|
|
||||||
});
|
});
|
||||||
await SystemModel.findOrCreate({
|
await SystemModel.getOrCreate({ type: AuthDataType.notification });
|
||||||
where: { type: AuthDataType.notification },
|
const authConfig = await SystemModel.getOrCreate({
|
||||||
});
|
type: AuthDataType.authConfig,
|
||||||
const [authConfig] = await SystemModel.findOrCreate({
|
|
||||||
where: { type: AuthDataType.authConfig },
|
|
||||||
});
|
});
|
||||||
if (!authConfig?.info || isDemoEnv()) {
|
if (!authConfig?.info || isDemoEnv()) {
|
||||||
let authInfo = {
|
let authInfo = {
|
||||||
@@ -71,36 +51,34 @@ export default async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn('Failed to read auth config file, using default credentials');
|
Logger.warn('Failed to read auth config file, using default credentials');
|
||||||
}
|
}
|
||||||
await SystemModel.upsert({
|
await SystemModel.nativeUpdate(
|
||||||
id: authConfig?.id,
|
{ id: authConfig.id },
|
||||||
info: authInfo,
|
{
|
||||||
type: AuthDataType.authConfig,
|
info: authInfo,
|
||||||
});
|
type: AuthDataType.authConfig,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const installDependencies = async () => {
|
const installDependencies = async () => {
|
||||||
const docs = await DependenceModel.findAll({
|
const docs = await DependenceModel.find(
|
||||||
where: {},
|
{},
|
||||||
order: [
|
{
|
||||||
['type', 'DESC'],
|
orderBy: orderBy([
|
||||||
['createdAt', 'DESC'],
|
['type', 'DESC'],
|
||||||
],
|
['createdAt', 'DESC'],
|
||||||
raw: true,
|
]),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
await DependenceModel.update(
|
await DependenceModel.nativeUpdate(
|
||||||
|
{ id: docs.map((x) => x.id!) },
|
||||||
{ status: DependenceStatus.queued, log: [] },
|
{ status: DependenceStatus.queued, log: [] },
|
||||||
{ where: { id: docs.map((x) => x.id!) } },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await dependenceService.installDependenceOneByOne(docs);
|
await dependenceService.installDependenceOneByOne(docs);
|
||||||
|
|
||||||
const bootAfterLoader = await import('./bootAfter');
|
const bootAfterLoader = await import('./bootAfter');
|
||||||
bootAfterLoader.default();
|
bootAfterLoader.default();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化更新 linux/python/nodejs 镜像源配置
|
// 初始化更新 linux/python/nodejs 镜像源配置
|
||||||
if (systemConfig.info?.pythonMirror) {
|
if (systemConfig.info?.pythonMirror) {
|
||||||
systemService.updatePythonMirror({
|
systemService.updatePythonMirror({
|
||||||
@@ -123,39 +101,40 @@ export default async () => {
|
|||||||
nodeMirror: systemConfig.info?.nodeMirror,
|
nodeMirror: systemConfig.info?.nodeMirror,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化新增默认全部任务视图
|
// 初始化新增默认全部任务视图
|
||||||
CrontabViewModel.findAll({
|
CrontabViewModel.find({ type: CronViewType.系统, name: '全部任务' }, {}).then(
|
||||||
where: { type: CronViewType.系统, name: '全部任务' },
|
(docs) => {
|
||||||
raw: true,
|
if (docs.length === 0) {
|
||||||
}).then((docs) => {
|
CrontabViewModel.insert({
|
||||||
if (docs.length === 0) {
|
name: '全部任务',
|
||||||
CrontabViewModel.create({
|
type: CronViewType.系统,
|
||||||
name: '全部任务',
|
position: initPosition / 2,
|
||||||
type: CronViewType.系统,
|
});
|
||||||
position: initPosition / 2,
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化更新所有任务状态为空闲
|
|
||||||
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
|
|
||||||
|
|
||||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
|
||||||
await RunningInstanceModel.update(
|
|
||||||
{ status: InstanceStatus.stopped },
|
|
||||||
{ where: { status: InstanceStatus.running } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化时执行一次所有的 ql repo 任务
|
|
||||||
CrontabModel.findAll({
|
|
||||||
where: {
|
|
||||||
isDisabled: { [Op.ne]: 1 },
|
|
||||||
command: {
|
|
||||||
[Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}).then((docs) => {
|
);
|
||||||
|
// 初始化更新所有任务状态为空闲
|
||||||
|
await CrontabModel.nativeUpdate({}, { status: CrontabStatus.idle });
|
||||||
|
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||||
|
await RunningInstanceModel.nativeUpdate(
|
||||||
|
{ status: InstanceStatus.running },
|
||||||
|
{
|
||||||
|
status: InstanceStatus.stopped,
|
||||||
|
finished_at: Math.floor(Date.now() / 1000),
|
||||||
|
// The previous process's exit code cannot be recovered after a restart.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 初始化时执行一次所有的 ql repo 任务
|
||||||
|
CrontabModel.find(
|
||||||
|
{
|
||||||
|
isDisabled: { ['$ne']: 1 },
|
||||||
|
$or: [
|
||||||
|
...textSearch(['command'], 'ql repo').$or,
|
||||||
|
...textSearch(['command'], 'ql raw').$or,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
).then((docs) => {
|
||||||
for (let i = 0; i < docs.length; i++) {
|
for (let i = 0; i < docs.length; i++) {
|
||||||
const doc = docs[i];
|
const doc = docs[i];
|
||||||
if (doc) {
|
if (doc) {
|
||||||
@@ -163,87 +142,79 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新2.11.3以前的脚本路径
|
// 更新2.11.3以前的脚本路径
|
||||||
CrontabModel.findAll({
|
CrontabModel.find(
|
||||||
where: {
|
{
|
||||||
command: {
|
$or: ['scripts', 'config', 'log', 'db'].flatMap(
|
||||||
[Op.or]: [
|
(folder) =>
|
||||||
{ [Op.like]: `%\/${config.rootPath}\/scripts\/%` },
|
textSearch(['command'], `/${config.rootPath}/${folder}/`).$or,
|
||||||
{ [Op.like]: `%\/${config.rootPath}\/config\/%` },
|
),
|
||||||
{ [Op.like]: `%\/${config.rootPath}\/log\/%` },
|
|
||||||
{ [Op.like]: `%\/${config.rootPath}\/db\/%` },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}).then(async (docs) => {
|
{},
|
||||||
|
).then(async (docs) => {
|
||||||
for (let i = 0; i < docs.length; i++) {
|
for (let i = 0; i < docs.length; i++) {
|
||||||
const doc = docs[i];
|
const doc = docs[i];
|
||||||
if (doc) {
|
if (doc) {
|
||||||
if (doc.command.includes(`${config.rootPath}/scripts/`)) {
|
if (doc.command.includes(`${config.rootPath}/scripts/`)) {
|
||||||
await CrontabModel.update(
|
await CrontabModel.nativeUpdate(
|
||||||
|
{ id: doc.id },
|
||||||
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
|
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
|
||||||
{ where: { id: doc.id } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (doc.command.includes(`${config.rootPath}/log/`)) {
|
if (doc.command.includes(`${config.rootPath}/log/`)) {
|
||||||
await CrontabModel.update(
|
await CrontabModel.nativeUpdate(
|
||||||
|
{ id: doc.id },
|
||||||
{
|
{
|
||||||
command: `${config.dataPath}/log/${doc.command.replace(
|
command: `${config.dataPath}/log/${doc.command.replace(
|
||||||
`${config.rootPath}/log/`,
|
`${config.rootPath}/log/`,
|
||||||
'',
|
'',
|
||||||
)}`,
|
)}`,
|
||||||
},
|
},
|
||||||
{ where: { id: doc.id } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (doc.command.includes(`${config.rootPath}/config/`)) {
|
if (doc.command.includes(`${config.rootPath}/config/`)) {
|
||||||
await CrontabModel.update(
|
await CrontabModel.nativeUpdate(
|
||||||
|
{ id: doc.id },
|
||||||
{
|
{
|
||||||
command: `${config.dataPath}/config/${doc.command.replace(
|
command: `${config.dataPath}/config/${doc.command.replace(
|
||||||
`${config.rootPath}/config/`,
|
`${config.rootPath}/config/`,
|
||||||
'',
|
'',
|
||||||
)}`,
|
)}`,
|
||||||
},
|
},
|
||||||
{ where: { id: doc.id } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (doc.command.includes(`${config.rootPath}/db/`)) {
|
if (doc.command.includes(`${config.rootPath}/db/`)) {
|
||||||
await CrontabModel.update(
|
await CrontabModel.nativeUpdate(
|
||||||
|
{ id: doc.id },
|
||||||
{
|
{
|
||||||
command: `${config.dataPath}/db/${doc.command.replace(
|
command: `${config.dataPath}/db/${doc.command.replace(
|
||||||
`${config.rootPath}/db/`,
|
`${config.rootPath}/db/`,
|
||||||
'',
|
'',
|
||||||
)}`,
|
)}`,
|
||||||
},
|
},
|
||||||
{ where: { id: doc.id } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化语言(必须在 autosave_crontab 之前)
|
// 初始化语言(必须在 autosave_crontab 之前)
|
||||||
const lang = systemConfig.info?.lang || systemLang();
|
const lang = systemConfig.info?.lang || systemLang();
|
||||||
setLang(lang);
|
setLang(lang);
|
||||||
|
|
||||||
// 确保 lang_env.sh 存在
|
// 确保 lang_env.sh 存在
|
||||||
try {
|
try {
|
||||||
const langEnvExist = await fileExist(config.langEnvFile);
|
const langEnvExist = await fileExist(config.langEnvFile);
|
||||||
if (!langEnvExist) {
|
if (!langEnvExist) {
|
||||||
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
|
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
// 初始化保存一次ck和定时任务数据
|
// 初始化保存一次ck和定时任务数据
|
||||||
await cronService.autosave_crontab();
|
cronClient.readiness.configure(async () => {
|
||||||
|
await cronService.autosave_crontab(true);
|
||||||
|
await systemService.restoreCronConcurrency();
|
||||||
|
});
|
||||||
|
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,23 +7,23 @@ 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);
|
||||||
const scheduleService = Container.get(ScheduleService);
|
const scheduleService = Container.get(ScheduleService);
|
||||||
const subscriptionService = Container.get(SubscriptionService);
|
const subscriptionService = Container.get(SubscriptionService);
|
||||||
const sshKeyService = Container.get(SshKeyService);
|
const sshKeyService = Container.get(SshKeyService);
|
||||||
|
|
||||||
// 生成内置token
|
// 生成内置token
|
||||||
let tokenCommand = `ts-node-transpile-only ${join(
|
let tokenCommand = `ts-node-transpile-only ${join(
|
||||||
config.rootPath,
|
config.rootPath,
|
||||||
'back/token.ts',
|
'back/token.ts',
|
||||||
)}`;
|
)}`;
|
||||||
const tokenFile = join(config.rootPath, 'static/build/token.js');
|
const tokenFile = join(config.rootPath, 'static/build/token.js');
|
||||||
|
|
||||||
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'),
|
||||||
@@ -38,7 +38,6 @@ export default async () => {
|
|||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 运行删除日志任务
|
// 运行删除日志任务
|
||||||
const data = await systemService.getSystemConfig();
|
const data = await systemService.getSystemConfig();
|
||||||
if (data && data.info) {
|
if (data && data.info) {
|
||||||
@@ -58,18 +57,21 @@ export default async () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
assertSubscriptionAlias(doc.alias);
|
||||||
|
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+66
-8
@@ -1,4 +1,4 @@
|
|||||||
import sockJs from 'sockjs';
|
import sockJs, { Connection } from 'sockjs';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import SockService from '../services/sock';
|
import SockService from '../services/sock';
|
||||||
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
export default async ({ server }: { server: Server }) => {
|
export default async ({ server }: { server: Server }) => {
|
||||||
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
|
const echo = sockJs.createServer({
|
||||||
|
prefix: `${config.baseUrl}/api/ws`,
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
const sockService = Container.get(SockService);
|
const sockService = Container.get(SockService);
|
||||||
|
const sessions = new Map<Connection, { token: string; platform: string }>();
|
||||||
|
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let checking = false;
|
||||||
|
|
||||||
|
const checkSessions = async () => {
|
||||||
|
if (checking || sessions.size === 0) return;
|
||||||
|
checking = true;
|
||||||
|
// Connections accepted during this read must not use an older snapshot.
|
||||||
|
const batch = [...sessions];
|
||||||
|
try {
|
||||||
|
const current = await shareStore.getAuthInfo();
|
||||||
|
// Reuse validation only within this synchronous check of one auth snapshot.
|
||||||
|
const validated = new Map<string, Map<string, boolean>>();
|
||||||
|
for (const [conn, { token, platform }] of batch) {
|
||||||
|
if (!sessions.has(conn)) continue;
|
||||||
|
let platforms = validated.get(token);
|
||||||
|
if (!platforms) {
|
||||||
|
platforms = new Map();
|
||||||
|
validated.set(token, platforms);
|
||||||
|
}
|
||||||
|
let valid = platforms.get(platform);
|
||||||
|
if (valid === undefined) {
|
||||||
|
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||||
|
platforms.set(platform, valid);
|
||||||
|
}
|
||||||
|
if (!valid) conn.close('401');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const [conn] of batch) {
|
||||||
|
if (sessions.has(conn)) conn.close('401');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
echo.on('connection', async (conn) => {
|
echo.on('connection', async (conn) => {
|
||||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||||
conn.close('404');
|
conn.close('404');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authInfo = await shareStore.getAuthInfo();
|
let closed = false;
|
||||||
|
conn.on('close', () => {
|
||||||
|
closed = true;
|
||||||
|
sessions.delete(conn);
|
||||||
|
sockService.removeClient(conn);
|
||||||
|
if (sessions.size === 0 && sessionTimer) {
|
||||||
|
clearInterval(sessionTimer);
|
||||||
|
sessionTimer = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let authInfo;
|
||||||
|
try {
|
||||||
|
authInfo = await shareStore.getAuthInfo();
|
||||||
|
} catch {
|
||||||
|
if (!closed) conn.close('401');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (closed) return;
|
||||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||||
|
|
||||||
if (isValidToken(authInfo, headerToken, platform)) {
|
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||||
sockService.addClient(conn);
|
sockService.addClient(conn);
|
||||||
|
sessions.set(conn, { token: headerToken, platform });
|
||||||
|
if (!sessionTimer) {
|
||||||
|
sessionTimer = setInterval(checkSessions, 1000);
|
||||||
|
sessionTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
conn.on('data', (message) => {
|
conn.on('data', (message) => {
|
||||||
conn.write(message);
|
conn.write(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('close', function () {
|
|
||||||
sockService.removeClient(conn);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
|
// Express 4 does not forward rejected async middleware promises itself.
|
||||||
|
export function withAuthAvailability(handler: RequestHandler): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => handler(req, res, next))
|
||||||
|
.catch(() => {
|
||||||
|
res.setHeader('Retry-After', '1');
|
||||||
|
next(
|
||||||
|
Object.assign(new Error(t('认证服务暂时不可用,请稍后重试')), {
|
||||||
|
status: 503,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,46 +3,34 @@ import Logger from '../loaders/logger';
|
|||||||
import { performance } from 'perf_hooks';
|
import { performance } from 'perf_hooks';
|
||||||
import { metricsService } from '../services/metrics';
|
import { metricsService } from '../services/metrics';
|
||||||
|
|
||||||
interface RequestMetrics {
|
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
|
||||||
method: string;
|
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
|
||||||
path: string;
|
let requestSampleOffset = 0;
|
||||||
duration: number;
|
|
||||||
statusCode: number;
|
|
||||||
timestamp: number;
|
|
||||||
platform?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestMetrics: RequestMetrics[] = [];
|
|
||||||
|
|
||||||
export const monitoringMiddleware = (
|
export const monitoringMiddleware = (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) => {
|
) => {
|
||||||
|
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const originalEnd = res.end;
|
const originalEnd = res.end;
|
||||||
|
|
||||||
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
const metric: RequestMetrics = {
|
const shouldSample = requestSampleOffset === 0;
|
||||||
method: req.method,
|
requestSampleOffset =
|
||||||
path: req.path,
|
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
|
||||||
duration,
|
if (shouldSample) {
|
||||||
statusCode: res.statusCode,
|
metricsService.record('http_request', duration, {
|
||||||
timestamp: Date.now(),
|
method: req.method,
|
||||||
platform: req.platform,
|
path: req.path,
|
||||||
};
|
statusCode: res.statusCode.toString(),
|
||||||
|
...(req.platform && { platform: req.platform }),
|
||||||
requestMetrics.push(metric);
|
});
|
||||||
metricsService.record('http_request', duration, {
|
|
||||||
method: req.method,
|
|
||||||
path: req.path,
|
|
||||||
statusCode: res.statusCode.toString(),
|
|
||||||
...(req.platform && { platform: req.platform }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestMetrics.length > 1000) {
|
|
||||||
requestMetrics.shift();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (duration > 1000) {
|
if (duration > 1000) {
|
||||||
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
|
|||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMetrics = () => {
|
|
||||||
return {
|
|
||||||
totalRequests: requestMetrics.length,
|
|
||||||
averageDuration:
|
|
||||||
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
|
|
||||||
requestMetrics.length,
|
|
||||||
requestsByMethod: requestMetrics.reduce((acc, curr) => {
|
|
||||||
acc[curr.method] = (acc[curr.method] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>),
|
|
||||||
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
|
|
||||||
if (curr.platform) {
|
|
||||||
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>),
|
|
||||||
recentRequests: requestMetrics.slice(-10),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
export default function protectedPathCase(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
const originalPath = req.path;
|
||||||
|
const normalizedPath = originalPath.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalPath !== normalizedPath &&
|
||||||
|
(normalizedPath.startsWith('/api/') ||
|
||||||
|
normalizedPath.startsWith('/open/'))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 400,
|
||||||
|
message: 'Invalid path format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ErrorRequestHandler } from 'express';
|
||||||
|
import { DatabaseError } from '../data/databaseErrors';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
// Handle the database constraint itself so concurrent writes are covered too.
|
||||||
|
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
|
||||||
|
if (!(err instanceof DatabaseError) || err.code !== 'UNIQUE_CONSTRAINT')
|
||||||
|
return next(err);
|
||||||
|
// Constraint details can contain environment values or application credentials.
|
||||||
|
res
|
||||||
|
.status(409)
|
||||||
|
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
|
||||||
|
};
|
||||||
|
export default uniqueConstraintError;
|
||||||
@@ -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;
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
type ColumnType = 'string' | 'integer' | 'text' | 'json';
|
|
||||||
|
|
||||||
interface LegacyColumn {
|
|
||||||
table: string;
|
|
||||||
column: string;
|
|
||||||
type: ColumnType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: LegacyColumn[] = [
|
|
||||||
{ table: 'CrontabViews', column: 'filterRelation', type: 'string' },
|
|
||||||
{ table: 'Subscriptions', column: 'proxy', type: 'string' },
|
|
||||||
{ table: 'CrontabViews', column: 'type', type: 'integer' },
|
|
||||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'integer' },
|
|
||||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'integer' },
|
|
||||||
{ table: 'Crontabs', column: 'sub_id', type: 'integer' },
|
|
||||||
{ table: 'Crontabs', column: 'extra_schedules', type: 'json' },
|
|
||||||
{ table: 'Crontabs', column: 'task_before', type: 'text' },
|
|
||||||
{ table: 'Crontabs', column: 'task_after', type: 'text' },
|
|
||||||
{ table: 'Crontabs', column: 'log_name', type: 'string' },
|
|
||||||
{
|
|
||||||
table: 'Crontabs',
|
|
||||||
column: 'allow_multiple_instances',
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
{ table: 'Crontabs', column: 'work_dir', type: 'string' },
|
|
||||||
{ table: 'Envs', column: 'isPinned', type: 'integer' },
|
|
||||||
{ table: 'Envs', column: 'labels', type: 'json' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const legacyColumnOwnership: readonly {
|
|
||||||
table: string;
|
|
||||||
column: string;
|
|
||||||
type: ColumnType;
|
|
||||||
}[] = columns;
|
|
||||||
|
|
||||||
function dataType(type: ColumnType) {
|
|
||||||
switch (type) {
|
|
||||||
case 'integer':
|
|
||||||
return DataTypes.INTEGER;
|
|
||||||
case 'text':
|
|
||||||
return DataTypes.TEXT;
|
|
||||||
case 'json':
|
|
||||||
return DataTypes.JSON;
|
|
||||||
default:
|
|
||||||
return DataTypes.STRING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const legacyColumnsMigration: Migration = {
|
|
||||||
id: '0001-legacy-columns',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(columns)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
const descriptions = new Map<string, Record<string, unknown>>();
|
|
||||||
|
|
||||||
for (const definition of columns) {
|
|
||||||
let description = descriptions.get(definition.table);
|
|
||||||
if (!description) {
|
|
||||||
description = await queryInterface.describeTable(definition.table);
|
|
||||||
descriptions.set(definition.table, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Object.prototype.hasOwnProperty.call(description, definition.column)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
definition.table,
|
|
||||||
definition.column,
|
|
||||||
{
|
|
||||||
type: dataType(definition.type),
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
description[definition.column] = {};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_TABLE = 'Runs';
|
|
||||||
export const RUN_ATTEMPT_TABLE = 'RunAttempts';
|
|
||||||
export const RUN_EVENT_TABLE = 'RunEvents';
|
|
||||||
|
|
||||||
const schemaManifest = {
|
|
||||||
version: 1,
|
|
||||||
tables: {
|
|
||||||
Runs: [
|
|
||||||
'id',
|
|
||||||
'project_id',
|
|
||||||
'task_id',
|
|
||||||
'task_revision',
|
|
||||||
'task_name',
|
|
||||||
'task_snapshot_ref',
|
|
||||||
'legacy_cron_id',
|
|
||||||
'parent_run_id',
|
|
||||||
'retry_of_run_id',
|
|
||||||
'trigger_id',
|
|
||||||
'trigger_type',
|
|
||||||
'execution_origin',
|
|
||||||
'execution_owner',
|
|
||||||
'triggered_by',
|
|
||||||
'request_id',
|
|
||||||
'scheduled_for_ms',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'event_sequence',
|
|
||||||
'priority',
|
|
||||||
'idempotency_key',
|
|
||||||
'input_ref',
|
|
||||||
'output_ref',
|
|
||||||
'created_at_ms',
|
|
||||||
'queued_at_ms',
|
|
||||||
'started_at_ms',
|
|
||||||
'finished_at_ms',
|
|
||||||
'error_code',
|
|
||||||
'error_summary',
|
|
||||||
],
|
|
||||||
RunAttempts: [
|
|
||||||
'id',
|
|
||||||
'run_id',
|
|
||||||
'step_run_id',
|
|
||||||
'attempt',
|
|
||||||
'status',
|
|
||||||
'executor_type',
|
|
||||||
'worker_id',
|
|
||||||
'executor_handle',
|
|
||||||
'pid',
|
|
||||||
'log_artifact_id',
|
|
||||||
'lease_token',
|
|
||||||
'lease_expires_at_ms',
|
|
||||||
'callback_token_hash',
|
|
||||||
'callback_sequence',
|
|
||||||
'created_at_ms',
|
|
||||||
'started_at_ms',
|
|
||||||
'finished_at_ms',
|
|
||||||
'exit_code',
|
|
||||||
'error_code',
|
|
||||||
'error_summary',
|
|
||||||
],
|
|
||||||
RunEvents: [
|
|
||||||
'id',
|
|
||||||
'run_id',
|
|
||||||
'sequence',
|
|
||||||
'type',
|
|
||||||
'dedupe_key',
|
|
||||||
'actor_type',
|
|
||||||
'actor_id',
|
|
||||||
'attempt_id',
|
|
||||||
'step_run_id',
|
|
||||||
'payload',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
indexes: [
|
|
||||||
'runs_project_created_idx',
|
|
||||||
'runs_task_created_idx',
|
|
||||||
'runs_status_queued_idx',
|
|
||||||
'runs_legacy_cron_created_idx',
|
|
||||||
'runs_project_idempotency_uidx',
|
|
||||||
'run_attempts_run_attempt_uidx',
|
|
||||||
'run_attempts_run_status_idx',
|
|
||||||
'run_attempts_status_created_idx',
|
|
||||||
'run_attempts_lease_idx',
|
|
||||||
'run_events_run_sequence_uidx',
|
|
||||||
'run_events_run_dedupe_uidx',
|
|
||||||
'run_events_run_created_idx',
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'runs_version_nonnegative_check',
|
|
||||||
'runs_event_sequence_nonnegative_check',
|
|
||||||
'run_attempts_attempt_positive_check',
|
|
||||||
'run_attempts_callback_sequence_nonnegative_check',
|
|
||||||
'run_events_sequence_positive_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runSchemaManifest = schemaManifest;
|
|
||||||
|
|
||||||
export const runSchemaMigration: Migration = {
|
|
||||||
id: '0002-run-schema',
|
|
||||||
checksum: createHash('sha256')
|
|
||||||
.update(JSON.stringify(schemaManifest))
|
|
||||||
.digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
|
||||||
project_id: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
task_id: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
task_revision: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
task_name: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
task_snapshot_ref: { type: DataTypes.STRING(512), allowNull: true },
|
|
||||||
legacy_cron_id: { type: DataTypes.INTEGER, allowNull: true },
|
|
||||||
parent_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
retry_of_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
trigger_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
trigger_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
execution_origin: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
execution_owner: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
triggered_by: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
request_id: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
scheduled_for_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
|
||||||
event_sequence: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
priority: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
idempotency_key: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
input_ref: { type: DataTypes.STRING(512), allowNull: true },
|
|
||||||
output_ref: { type: DataTypes.STRING(512), allowNull: true },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
queued_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
error_code: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
error_summary: { type: DataTypes.STRING(1024), allowNull: true },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
step_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
attempt: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
executor_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
worker_id: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
executor_handle: { type: DataTypes.TEXT, allowNull: true },
|
|
||||||
pid: { type: DataTypes.INTEGER, allowNull: true },
|
|
||||||
log_artifact_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
callback_token_hash: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
callback_sequence: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
exit_code: { type: DataTypes.INTEGER, allowNull: true },
|
|
||||||
error_code: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
error_summary: { type: DataTypes.STRING(1024), allowNull: true },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_EVENT_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
type: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
dedupe_key: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
actor_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
actor_id: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: true,
|
|
||||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'SET NULL',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
step_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
payload: { type: DataTypes.JSON, allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryInterface.addConstraint(RUN_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 0 } },
|
|
||||||
name: 'runs_version_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_TABLE, {
|
|
||||||
fields: ['event_sequence'],
|
|
||||||
type: 'check',
|
|
||||||
where: { event_sequence: { [Op.gte]: 0 } },
|
|
||||||
name: 'runs_event_sequence_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_ATTEMPT_TABLE, {
|
|
||||||
fields: ['attempt'],
|
|
||||||
type: 'check',
|
|
||||||
where: { attempt: { [Op.gte]: 1 } },
|
|
||||||
name: 'run_attempts_attempt_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_ATTEMPT_TABLE, {
|
|
||||||
fields: ['callback_sequence'],
|
|
||||||
type: 'check',
|
|
||||||
where: { callback_sequence: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_attempts_callback_sequence_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_EVENT_TABLE, {
|
|
||||||
fields: ['sequence'],
|
|
||||||
type: 'check',
|
|
||||||
where: { sequence: { [Op.gte]: 1 } },
|
|
||||||
name: 'run_events_sequence_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.addIndex(RUN_TABLE, ['project_id', 'created_at_ms'], {
|
|
||||||
name: 'runs_project_created_idx',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(RUN_TABLE, ['task_id', 'created_at_ms'], {
|
|
||||||
name: 'runs_task_created_idx',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(RUN_TABLE, ['status', 'queued_at_ms'], {
|
|
||||||
name: 'runs_status_queued_idx',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_TABLE,
|
|
||||||
['legacy_cron_id', 'created_at_ms'],
|
|
||||||
{ name: 'runs_legacy_cron_created_idx', transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_TABLE,
|
|
||||||
['project_id', 'idempotency_key'],
|
|
||||||
{
|
|
||||||
name: 'runs_project_idempotency_uidx',
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['run_id', 'attempt'], {
|
|
||||||
name: 'run_attempts_run_attempt_uidx',
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['run_id', 'status'], {
|
|
||||||
name: 'run_attempts_run_status_idx',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
['status', 'created_at_ms'],
|
|
||||||
{ name: 'run_attempts_status_created_idx', transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['lease_expires_at_ms'], {
|
|
||||||
name: 'run_attempts_lease_idx',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.addIndex(RUN_EVENT_TABLE, ['run_id', 'sequence'], {
|
|
||||||
name: 'run_events_run_sequence_uidx',
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(RUN_EVENT_TABLE, ['run_id', 'dedupe_key'], {
|
|
||||||
name: 'run_events_run_dedupe_uidx',
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_EVENT_TABLE,
|
|
||||||
['run_id', 'created_at_ms'],
|
|
||||||
{ name: 'run_events_run_created_idx', transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUNNING_INSTANCE_TABLE = 'RunningInstances';
|
|
||||||
export const RUNNING_INSTANCE_RUN_INDEX = 'running_instances_run_started_idx';
|
|
||||||
export const RUNNING_INSTANCE_ATTEMPT_INDEX = 'running_instances_attempt_uidx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUNNING_INSTANCE_TABLE,
|
|
||||||
columns: {
|
|
||||||
run_id: 'varchar(36) null',
|
|
||||||
attempt_id: 'varchar(36) null',
|
|
||||||
},
|
|
||||||
indexes: [
|
|
||||||
`${RUNNING_INSTANCE_RUN_INDEX}(run_id,started_at)`,
|
|
||||||
`${RUNNING_INSTANCE_ATTEMPT_INDEX}(attempt_id) unique`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runningInstanceRunReferenceManifest = manifest;
|
|
||||||
|
|
||||||
export const runningInstanceRunReferenceMigration: Migration = {
|
|
||||||
id: '0003-running-instance-run-reference',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
const tables = new Set(await queryInterface.showAllTables());
|
|
||||||
if (!tables.has(RUNNING_INSTANCE_TABLE)) return;
|
|
||||||
|
|
||||||
const columns = await queryInterface.describeTable(RUNNING_INSTANCE_TABLE);
|
|
||||||
if (!columns.run_id) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
RUNNING_INSTANCE_TABLE,
|
|
||||||
'run_id',
|
|
||||||
{ type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!columns.attempt_id) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
RUNNING_INSTANCE_TABLE,
|
|
||||||
'attempt_id',
|
|
||||||
{ type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndexes = (await queryInterface.showIndex(
|
|
||||||
RUNNING_INSTANCE_TABLE,
|
|
||||||
{ transaction },
|
|
||||||
)) as Array<{ name: string }>;
|
|
||||||
const indexes = new Set(currentIndexes.map((index) => index.name));
|
|
||||||
if (!indexes.has(RUNNING_INSTANCE_RUN_INDEX)) {
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUNNING_INSTANCE_TABLE,
|
|
||||||
['run_id', 'started_at'],
|
|
||||||
{
|
|
||||||
name: RUNNING_INSTANCE_RUN_INDEX,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!indexes.has(RUNNING_INSTANCE_ATTEMPT_INDEX)) {
|
|
||||||
await queryInterface.addIndex(RUNNING_INSTANCE_TABLE, ['attempt_id'], {
|
|
||||||
name: RUNNING_INSTANCE_ATTEMPT_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import { RUN_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_CANCELLATION_REQUEST_INDEX =
|
|
||||||
'runs_status_cancel_requested_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUN_TABLE,
|
|
||||||
columns: {
|
|
||||||
cancel_requested_at_ms: 'bigint null',
|
|
||||||
cancel_reason: 'varchar(32) null',
|
|
||||||
},
|
|
||||||
indexes: [`${RUN_CANCELLATION_REQUEST_INDEX}(status,cancel_requested_at_ms)`],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runCancellationRequestManifest = manifest;
|
|
||||||
|
|
||||||
export const runCancellationRequestMigration: Migration = {
|
|
||||||
id: '0004-run-cancellation-request',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
const tables = new Set(await queryInterface.showAllTables());
|
|
||||||
if (!tables.has(RUN_TABLE)) return;
|
|
||||||
|
|
||||||
const columns = await queryInterface.describeTable(RUN_TABLE);
|
|
||||||
if (!columns.cancel_requested_at_ms) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
RUN_TABLE,
|
|
||||||
'cancel_requested_at_ms',
|
|
||||||
{ type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!columns.cancel_reason) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
RUN_TABLE,
|
|
||||||
'cancel_reason',
|
|
||||||
{ type: DataTypes.STRING(32), allowNull: true },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndexes = (await queryInterface.showIndex(RUN_TABLE, {
|
|
||||||
transaction,
|
|
||||||
})) as Array<{ name: string }>;
|
|
||||||
if (
|
|
||||||
!currentIndexes.some(
|
|
||||||
(index) => index.name === RUN_CANCELLATION_REQUEST_INDEX,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_TABLE,
|
|
||||||
['status', 'cancel_requested_at_ms'],
|
|
||||||
{ name: RUN_CANCELLATION_REQUEST_INDEX, transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_CANCELLATION_DISPATCH_TABLE = 'RunCancellationDispatches';
|
|
||||||
export const RUN_CANCELLATION_DISPATCH_DUE_INDEX =
|
|
||||||
'run_cancel_dispatch_due_idx';
|
|
||||||
export const RUN_CANCELLATION_DISPATCH_LEASE_INDEX =
|
|
||||||
'run_cancel_dispatch_lease_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUN_CANCELLATION_DISPATCH_TABLE,
|
|
||||||
columns: [
|
|
||||||
'run_id',
|
|
||||||
'attempt_id',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'dispatch_count',
|
|
||||||
'next_attempt_at_ms',
|
|
||||||
'lease_owner',
|
|
||||||
'lease_token',
|
|
||||||
'lease_expires_at_ms',
|
|
||||||
'last_result',
|
|
||||||
'last_dispatched_at_ms',
|
|
||||||
'created_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${RUN_CANCELLATION_DISPATCH_DUE_INDEX}(status,next_attempt_at_ms)`,
|
|
||||||
`${RUN_CANCELLATION_DISPATCH_LEASE_INDEX}(lease_expires_at_ms)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'run_cancel_dispatch_version_nonnegative_check',
|
|
||||||
'run_cancel_dispatch_count_nonnegative_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runCancellationDispatchManifest = manifest;
|
|
||||||
|
|
||||||
export const runCancellationDispatchMigration: Migration = {
|
|
||||||
id: '0005-run-cancellation-dispatch',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
|
||||||
{
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
|
||||||
dispatch_count: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
last_result: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
last_dispatched_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(RUN_CANCELLATION_DISPATCH_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_cancel_dispatch_version_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_CANCELLATION_DISPATCH_TABLE, {
|
|
||||||
fields: ['dispatch_count'],
|
|
||||||
type: 'check',
|
|
||||||
where: { dispatch_count: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_cancel_dispatch_count_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
|
||||||
['status', 'next_attempt_at_ms'],
|
|
||||||
{ name: RUN_CANCELLATION_DISPATCH_DUE_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
|
||||||
['lease_expires_at_ms'],
|
|
||||||
{ name: RUN_CANCELLATION_DISPATCH_LEASE_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_ATTEMPT_DEADLINE_INDEX = 'run_attempt_status_deadline_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUN_ATTEMPT_TABLE,
|
|
||||||
columns: {
|
|
||||||
deadline_at_ms: 'bigint null',
|
|
||||||
},
|
|
||||||
indexes: [`${RUN_ATTEMPT_DEADLINE_INDEX}(status,deadline_at_ms,id)`],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runAttemptDeadlineManifest = manifest;
|
|
||||||
|
|
||||||
export const runAttemptDeadlineMigration: Migration = {
|
|
||||||
id: '0006-run-attempt-deadline',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
const tables = new Set(await queryInterface.showAllTables());
|
|
||||||
if (!tables.has(RUN_ATTEMPT_TABLE)) return;
|
|
||||||
|
|
||||||
const columns = await queryInterface.describeTable(RUN_ATTEMPT_TABLE);
|
|
||||||
if (!columns.deadline_at_ms) {
|
|
||||||
await queryInterface.addColumn(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
'deadline_at_ms',
|
|
||||||
{ type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndexes = (await queryInterface.showIndex(RUN_ATTEMPT_TABLE, {
|
|
||||||
transaction,
|
|
||||||
})) as Array<{ name: string }>;
|
|
||||||
if (
|
|
||||||
!currentIndexes.some((index) => index.name === RUN_ATTEMPT_DEADLINE_INDEX)
|
|
||||||
) {
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
['status', 'deadline_at_ms', 'id'],
|
|
||||||
{ name: RUN_ATTEMPT_DEADLINE_INDEX, transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const COMPLETION_RECEIPT_JOURNAL_TABLE = 'CompletionReceiptJournals';
|
|
||||||
export const COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX =
|
|
||||||
'completion_receipt_journal_scan_idx';
|
|
||||||
export const COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX =
|
|
||||||
'completion_receipt_journal_purge_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: COMPLETION_RECEIPT_JOURNAL_TABLE,
|
|
||||||
columns: [
|
|
||||||
'attempt_id',
|
|
||||||
'run_id',
|
|
||||||
'state',
|
|
||||||
'quarantine_ref',
|
|
||||||
'purge_after_ms',
|
|
||||||
'registered_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX}(state,updated_at_ms,attempt_id)`,
|
|
||||||
`${COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX}(state,purge_after_ms,attempt_id)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'completion_receipt_journal_state_check',
|
|
||||||
'completion_receipt_journal_registered_nonnegative_check',
|
|
||||||
'completion_receipt_journal_updated_nonnegative_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const completionReceiptJournalManifest = manifest;
|
|
||||||
|
|
||||||
export const completionReceiptJournalMigration: Migration = {
|
|
||||||
id: '0007-completion-receipt-journal',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
|
||||||
{
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
type: DataTypes.STRING(16),
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'pending',
|
|
||||||
},
|
|
||||||
quarantine_ref: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
purge_after_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
registered_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
|
||||||
fields: ['state'],
|
|
||||||
type: 'check',
|
|
||||||
where: { state: { [Op.in]: ['pending', 'quarantined'] } },
|
|
||||||
name: 'completion_receipt_journal_state_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
|
||||||
fields: ['registered_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { registered_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'completion_receipt_journal_registered_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
|
||||||
fields: ['updated_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { updated_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'completion_receipt_journal_updated_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
|
||||||
['state', 'updated_at_ms', 'attempt_id'],
|
|
||||||
{ name: COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
|
||||||
['state', 'purge_after_ms', 'attempt_id'],
|
|
||||||
{ name: COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const WORKER_REGISTRY_TABLE = 'Workers';
|
|
||||||
export const WORKER_REGISTRY_LEASE_INDEX = 'workers_lease_idx';
|
|
||||||
export const WORKER_REGISTRY_CAPACITY_INDEX = 'workers_capacity_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: WORKER_REGISTRY_TABLE,
|
|
||||||
columns: [
|
|
||||||
'id',
|
|
||||||
'session_id',
|
|
||||||
'generation',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'capabilities_json',
|
|
||||||
'capabilities_hash',
|
|
||||||
'max_concurrent_runs',
|
|
||||||
'available_slots',
|
|
||||||
'registered_at_ms',
|
|
||||||
'last_heartbeat_at_ms',
|
|
||||||
'lease_expires_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${WORKER_REGISTRY_LEASE_INDEX}(status,lease_expires_at_ms,id)`,
|
|
||||||
`${WORKER_REGISTRY_CAPACITY_INDEX}(status,available_slots,lease_expires_at_ms,id)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'workers_generation_positive_check',
|
|
||||||
'workers_version_nonnegative_check',
|
|
||||||
'workers_max_concurrency_positive_check',
|
|
||||||
'workers_available_slots_nonnegative_check',
|
|
||||||
'workers_status_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const workerRegistryManifest = manifest;
|
|
||||||
|
|
||||||
export const workerRegistryMigration: Migration = {
|
|
||||||
id: '0008-worker-registry',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
WORKER_REGISTRY_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
|
||||||
session_id: { type: DataTypes.STRING(36), allowNull: false },
|
|
||||||
generation: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
capabilities_json: { type: DataTypes.TEXT, allowNull: false },
|
|
||||||
capabilities_hash: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
max_concurrent_runs: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
available_slots: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
registered_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
last_heartbeat_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
|
||||||
fields: ['generation'],
|
|
||||||
type: 'check',
|
|
||||||
where: { generation: { [Op.gt]: 0 } },
|
|
||||||
name: 'workers_generation_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 0 } },
|
|
||||||
name: 'workers_version_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
|
||||||
fields: ['max_concurrent_runs'],
|
|
||||||
type: 'check',
|
|
||||||
where: { max_concurrent_runs: { [Op.gt]: 0 } },
|
|
||||||
name: 'workers_max_concurrency_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
|
||||||
fields: ['available_slots'],
|
|
||||||
type: 'check',
|
|
||||||
where: { available_slots: { [Op.gte]: 0 } },
|
|
||||||
name: 'workers_available_slots_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
|
||||||
fields: ['status'],
|
|
||||||
type: 'check',
|
|
||||||
where: { status: { [Op.in]: ['online', 'draining', 'offline'] } },
|
|
||||||
name: 'workers_status_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
WORKER_REGISTRY_TABLE,
|
|
||||||
['status', 'lease_expires_at_ms', 'id'],
|
|
||||||
{ name: WORKER_REGISTRY_LEASE_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
WORKER_REGISTRY_TABLE,
|
|
||||||
['status', 'available_slots', 'lease_expires_at_ms', 'id'],
|
|
||||||
{ name: WORKER_REGISTRY_CAPACITY_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
|
||||||
import { WORKER_REGISTRY_TABLE } from './0008-worker-registry';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_DISPATCH_LEASE_TABLE = 'RunDispatchLeases';
|
|
||||||
export const RUN_DISPATCH_LEASE_EXPIRY_INDEX = 'run_dispatch_leases_expiry_idx';
|
|
||||||
export const RUN_DISPATCH_LEASE_WORKER_INDEX = 'run_dispatch_leases_worker_idx';
|
|
||||||
export const RUN_DISPATCH_LEASE_TOKEN_INDEX = 'run_dispatch_leases_token_uidx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUN_DISPATCH_LEASE_TABLE,
|
|
||||||
columns: [
|
|
||||||
'attempt_id',
|
|
||||||
'run_id',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'lease_generation',
|
|
||||||
'worker_id',
|
|
||||||
'worker_session_id',
|
|
||||||
'worker_generation',
|
|
||||||
'lease_token',
|
|
||||||
'acquired_at_ms',
|
|
||||||
'renewed_at_ms',
|
|
||||||
'expires_at_ms',
|
|
||||||
'released_at_ms',
|
|
||||||
'release_reason',
|
|
||||||
'completed_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${RUN_DISPATCH_LEASE_EXPIRY_INDEX}(status,expires_at_ms,attempt_id)`,
|
|
||||||
`${RUN_DISPATCH_LEASE_WORKER_INDEX}(worker_id,worker_session_id,worker_generation,status,expires_at_ms,attempt_id)`,
|
|
||||||
`${RUN_DISPATCH_LEASE_TOKEN_INDEX}(lease_token)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'run_dispatch_leases_status_check',
|
|
||||||
'run_dispatch_leases_version_nonnegative_check',
|
|
||||||
'run_dispatch_leases_generation_positive_check',
|
|
||||||
'run_dispatch_leases_worker_generation_positive_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runDispatchLeaseManifest = manifest;
|
|
||||||
|
|
||||||
export const runDispatchLeaseMigration: Migration = {
|
|
||||||
id: '0009-run-dispatch-lease',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_DISPATCH_LEASE_TABLE,
|
|
||||||
{
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
lease_generation: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
worker_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: WORKER_REGISTRY_TABLE, key: 'id' },
|
|
||||||
onDelete: 'RESTRICT',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
worker_session_id: { type: DataTypes.STRING(36), allowNull: false },
|
|
||||||
worker_generation: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
lease_token: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
acquired_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
renewed_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
released_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
release_reason: { type: DataTypes.STRING(32), allowNull: true },
|
|
||||||
completed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
|
||||||
fields: ['status'],
|
|
||||||
type: 'check',
|
|
||||||
where: { status: { [Op.in]: ['leased', 'released', 'completed'] } },
|
|
||||||
name: 'run_dispatch_leases_status_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_dispatch_leases_version_nonnegative_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
|
||||||
fields: ['lease_generation'],
|
|
||||||
type: 'check',
|
|
||||||
where: { lease_generation: { [Op.gt]: 0 } },
|
|
||||||
name: 'run_dispatch_leases_generation_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
|
||||||
fields: ['worker_generation'],
|
|
||||||
type: 'check',
|
|
||||||
where: { worker_generation: { [Op.gt]: 0 } },
|
|
||||||
name: 'run_dispatch_leases_worker_generation_positive_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_DISPATCH_LEASE_TABLE,
|
|
||||||
['status', 'expires_at_ms', 'attempt_id'],
|
|
||||||
{ name: RUN_DISPATCH_LEASE_EXPIRY_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_DISPATCH_LEASE_TABLE,
|
|
||||||
[
|
|
||||||
'worker_id',
|
|
||||||
'worker_session_id',
|
|
||||||
'worker_generation',
|
|
||||||
'status',
|
|
||||||
'expires_at_ms',
|
|
||||||
'attempt_id',
|
|
||||||
],
|
|
||||||
{ name: RUN_DISPATCH_LEASE_WORKER_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(RUN_DISPATCH_LEASE_TABLE, ['lease_token'], {
|
|
||||||
name: RUN_DISPATCH_LEASE_TOKEN_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { Op } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_DISPATCH_CANDIDATE_RUN_INDEX = 'runs_dispatch_candidates_idx';
|
|
||||||
export const RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX =
|
|
||||||
'run_attempts_dispatch_candidates_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
indexes: [
|
|
||||||
`${RUN_DISPATCH_CANDIDATE_RUN_INDEX}(priority DESC,queued_at_ms,id) WHERE runtime queued/dispatching uncancelled`,
|
|
||||||
`${RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX}(status,run_id,created_at_ms,id)`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runDispatchCandidateManifest = manifest;
|
|
||||||
|
|
||||||
export const runDispatchCandidateMigration: Migration = {
|
|
||||||
id: '0010-run-dispatch-candidates',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.addIndex(RUN_TABLE, {
|
|
||||||
fields: [{ name: 'priority', order: 'DESC' }, 'queued_at_ms', 'id'],
|
|
||||||
name: RUN_DISPATCH_CANDIDATE_RUN_INDEX,
|
|
||||||
where: {
|
|
||||||
execution_owner: 'runtime',
|
|
||||||
status: { [Op.in]: ['queued', 'dispatching'] },
|
|
||||||
cancel_requested_at_ms: null,
|
|
||||||
queued_at_ms: { [Op.ne]: null },
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
['status', 'run_id', 'created_at_ms', 'id'],
|
|
||||||
{ name: RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { RUN_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const RUN_RETRY_POLICY_TABLE = 'RunRetryPolicies';
|
|
||||||
export const RUN_RETRY_POLICY_DUE_INDEX = 'run_retry_policies_due_idx';
|
|
||||||
export const RUN_LOST_RETRY_INDEX = 'runs_lost_retry_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: RUN_RETRY_POLICY_TABLE,
|
|
||||||
columns: [
|
|
||||||
'run_id',
|
|
||||||
'max_attempts',
|
|
||||||
'retry_on_lost',
|
|
||||||
'safety',
|
|
||||||
'backoff_base_ms',
|
|
||||||
'backoff_max_ms',
|
|
||||||
'next_attempt_at_ms',
|
|
||||||
'version',
|
|
||||||
'created_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${RUN_LOST_RETRY_INDEX}(execution_owner,status,id)`,
|
|
||||||
`${RUN_RETRY_POLICY_DUE_INDEX}(next_attempt_at_ms,run_id) WHERE next_attempt_at_ms IS NOT NULL`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'run_retry_policies_max_attempts_check',
|
|
||||||
'run_retry_policies_backoff_base_check',
|
|
||||||
'run_retry_policies_backoff_max_check',
|
|
||||||
'run_retry_policies_version_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runRetryPolicyManifest = manifest;
|
|
||||||
|
|
||||||
export const runRetryPolicyMigration: Migration = {
|
|
||||||
id: '0011-run-retry-policy',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
RUN_RETRY_POLICY_TABLE,
|
|
||||||
{
|
|
||||||
run_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: RUN_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
max_attempts: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
retry_on_lost: { type: DataTypes.BOOLEAN, allowNull: false },
|
|
||||||
safety: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
backoff_base_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
backoff_max_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
version: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
|
||||||
fields: ['max_attempts'],
|
|
||||||
type: 'check',
|
|
||||||
where: { max_attempts: { [Op.between]: [1, 16] } },
|
|
||||||
name: 'run_retry_policies_max_attempts_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
|
||||||
fields: ['backoff_base_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { backoff_base_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_retry_policies_backoff_base_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
|
||||||
fields: ['backoff_max_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { backoff_max_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_retry_policies_backoff_max_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 0 } },
|
|
||||||
name: 'run_retry_policies_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_TABLE,
|
|
||||||
['execution_owner', 'status', 'id'],
|
|
||||||
{
|
|
||||||
name: RUN_LOST_RETRY_INDEX,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_RETRY_POLICY_TABLE,
|
|
||||||
['next_attempt_at_ms', 'run_id'],
|
|
||||||
{
|
|
||||||
name: RUN_RETRY_POLICY_DUE_INDEX,
|
|
||||||
where: { next_attempt_at_ms: { [Op.ne]: null } },
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const TASK_EXECUTION_REVISION_TABLE = 'TaskExecutionRevisions';
|
|
||||||
export const TASK_EXECUTION_REVISION_CREATED_INDEX =
|
|
||||||
'task_execution_revisions_created_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: TASK_EXECUTION_REVISION_TABLE,
|
|
||||||
columns: [
|
|
||||||
'project_id',
|
|
||||||
'task_id',
|
|
||||||
'task_revision',
|
|
||||||
'executor_type',
|
|
||||||
'execution_template',
|
|
||||||
'context_ref',
|
|
||||||
'content_digest',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${TASK_EXECUTION_REVISION_CREATED_INDEX}(project_id,created_at_ms,task_id,task_revision)`,
|
|
||||||
],
|
|
||||||
constraints: ['task_execution_revisions_created_at_check'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const taskExecutionRevisionManifest = manifest;
|
|
||||||
|
|
||||||
export const taskExecutionRevisionMigration: Migration = {
|
|
||||||
id: '0012-task-execution-revisions',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
TASK_EXECUTION_REVISION_TABLE,
|
|
||||||
{
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
task_id: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
task_revision: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
executor_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
execution_template: { type: DataTypes.TEXT, allowNull: false },
|
|
||||||
context_ref: { type: DataTypes.STRING(512), allowNull: false },
|
|
||||||
content_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(TASK_EXECUTION_REVISION_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'task_execution_revisions_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
TASK_EXECUTION_REVISION_TABLE,
|
|
||||||
['project_id', 'created_at_ms', 'task_id', 'task_revision'],
|
|
||||||
{ name: TASK_EXECUTION_REVISION_CREATED_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE =
|
|
||||||
'LocalExecutionContextRecipes';
|
|
||||||
export const LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX =
|
|
||||||
'local_execution_context_recipes_created_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
|
||||||
columns: [
|
|
||||||
'context_ref',
|
|
||||||
'environment_recipe',
|
|
||||||
'content_digest',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX}(created_at_ms,context_ref)`,
|
|
||||||
],
|
|
||||||
constraints: ['local_execution_context_recipes_created_at_check'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const localExecutionContextRecipeManifest = manifest;
|
|
||||||
|
|
||||||
export const localExecutionContextRecipeMigration: Migration = {
|
|
||||||
id: '0013-local-execution-context-recipes',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
|
||||||
{
|
|
||||||
context_ref: {
|
|
||||||
type: DataTypes.STRING(512),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
environment_recipe: { type: DataTypes.TEXT, allowNull: false },
|
|
||||||
content_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'local_execution_context_recipes_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
|
||||||
['created_at_ms', 'context_ref'],
|
|
||||||
{ name: LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const LOCAL_SECRET_ENVELOPE_TABLE = 'LocalSecretEnvelopes';
|
|
||||||
export const LOCAL_SECRET_MUTATION_INDEX = 'local_secret_mutation_idx';
|
|
||||||
export const LOCAL_SECRET_CURRENT_INDEX = 'local_secret_current_idx';
|
|
||||||
export const LOCAL_SECRET_KEY_INDEX = 'local_secret_key_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: LOCAL_SECRET_ENVELOPE_TABLE,
|
|
||||||
columns: [
|
|
||||||
'project_id',
|
|
||||||
'secret_name',
|
|
||||||
'version',
|
|
||||||
'mutation_id',
|
|
||||||
'key_id',
|
|
||||||
'algorithm',
|
|
||||||
'nonce',
|
|
||||||
'ciphertext',
|
|
||||||
'auth_tag',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${LOCAL_SECRET_MUTATION_INDEX}(project_id,secret_name,mutation_id) UNIQUE`,
|
|
||||||
`${LOCAL_SECRET_CURRENT_INDEX}(project_id,secret_name,version DESC)`,
|
|
||||||
`${LOCAL_SECRET_KEY_INDEX}(key_id,project_id,secret_name,version)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'local_secret_envelopes_version_check',
|
|
||||||
'local_secret_envelopes_created_at_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const localSecretEnvelopeManifest = manifest;
|
|
||||||
|
|
||||||
export const localSecretEnvelopeMigration: Migration = {
|
|
||||||
id: '0014-local-secret-envelopes',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
|
||||||
{
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
secret_name: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
version: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
key_id: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
algorithm: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
nonce: { type: DataTypes.BLOB, allowNull: false },
|
|
||||||
ciphertext: { type: DataTypes.BLOB, allowNull: false },
|
|
||||||
auth_tag: { type: DataTypes.BLOB, allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(LOCAL_SECRET_ENVELOPE_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 1 } },
|
|
||||||
name: 'local_secret_envelopes_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(LOCAL_SECRET_ENVELOPE_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'local_secret_envelopes_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
|
||||||
['project_id', 'secret_name', 'mutation_id'],
|
|
||||||
{ name: LOCAL_SECRET_MUTATION_INDEX, unique: true, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(LOCAL_SECRET_ENVELOPE_TABLE, {
|
|
||||||
fields: ['project_id', 'secret_name', { name: 'version', order: 'DESC' }],
|
|
||||||
name: LOCAL_SECRET_CURRENT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
|
||||||
['key_id', 'project_id', 'secret_name', 'version'],
|
|
||||||
{ name: LOCAL_SECRET_KEY_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { RUN_ATTEMPT_TABLE } from './0002-run-schema';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const LOCAL_ARTIFACT_RETENTION_TABLE = 'LocalArtifactRetentions';
|
|
||||||
export const LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX =
|
|
||||||
'local_artifact_retention_recorded_idx';
|
|
||||||
export const RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX =
|
|
||||||
'run_attempts_artifact_retention_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: LOCAL_ARTIFACT_RETENTION_TABLE,
|
|
||||||
columns: [
|
|
||||||
'attempt_id',
|
|
||||||
'log_artifact_id',
|
|
||||||
'finished_at_ms',
|
|
||||||
'eligible_at_ms',
|
|
||||||
'disposition',
|
|
||||||
'bytes_reclaimed',
|
|
||||||
'recorded_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX}(recorded_at_ms,attempt_id)`,
|
|
||||||
`${RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX}(status,finished_at_ms,id)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'local_artifact_retention_disposition_check',
|
|
||||||
'local_artifact_retention_finished_check',
|
|
||||||
'local_artifact_retention_eligible_check',
|
|
||||||
'local_artifact_retention_bytes_check',
|
|
||||||
'local_artifact_retention_recorded_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const localArtifactRetentionManifest = manifest;
|
|
||||||
|
|
||||||
export const localArtifactRetentionMigration: Migration = {
|
|
||||||
id: '0015-local-artifact-retention',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
LOCAL_ARTIFACT_RETENTION_TABLE,
|
|
||||||
{
|
|
||||||
attempt_id: {
|
|
||||||
type: DataTypes.STRING(36),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
log_artifact_id: { type: DataTypes.STRING(36), allowNull: false },
|
|
||||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
eligible_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
disposition: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
bytes_reclaimed: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
recorded_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(LOCAL_ARTIFACT_RETENTION_TABLE, {
|
|
||||||
fields: ['disposition'],
|
|
||||||
type: 'check',
|
|
||||||
where: { disposition: { [Op.in]: ['deleted', 'already_absent'] } },
|
|
||||||
name: 'local_artifact_retention_disposition_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
for (const [field, name] of [
|
|
||||||
['finished_at_ms', 'local_artifact_retention_finished_check'],
|
|
||||||
['eligible_at_ms', 'local_artifact_retention_eligible_check'],
|
|
||||||
['bytes_reclaimed', 'local_artifact_retention_bytes_check'],
|
|
||||||
['recorded_at_ms', 'local_artifact_retention_recorded_check'],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(LOCAL_ARTIFACT_RETENTION_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.gte]: 0 } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
LOCAL_ARTIFACT_RETENTION_TABLE,
|
|
||||||
['recorded_at_ms', 'attempt_id'],
|
|
||||||
{ name: LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX, transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
RUN_ATTEMPT_TABLE,
|
|
||||||
['status', 'finished_at_ms', 'id'],
|
|
||||||
{ name: RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX, transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE =
|
|
||||||
'LocalArtifactMaintenanceCursors';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
columns: [
|
|
||||||
'scope',
|
|
||||||
'cursor_finished_at_ms',
|
|
||||||
'cursor_attempt_id',
|
|
||||||
'version',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
indexes: [] as string[],
|
|
||||||
constraints: [
|
|
||||||
'local_artifact_cursor_scope_check',
|
|
||||||
'local_artifact_cursor_pair_check',
|
|
||||||
'local_artifact_cursor_version_check',
|
|
||||||
'local_artifact_cursor_updated_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const localArtifactMaintenanceCursorManifest = manifest;
|
|
||||||
|
|
||||||
export const localArtifactMaintenanceCursorMigration: Migration = {
|
|
||||||
id: '0016-local-artifact-maintenance-cursor',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
{
|
|
||||||
scope: {
|
|
||||||
type: DataTypes.STRING(32),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
cursor_finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
cursor_attempt_id: { type: DataTypes.STRING(36), allowNull: true },
|
|
||||||
version: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['scope'],
|
|
||||||
type: 'check',
|
|
||||||
where: { scope: 'retention' },
|
|
||||||
name: 'local_artifact_cursor_scope_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['cursor_finished_at_ms', 'cursor_attempt_id'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{ cursor_finished_at_ms: null, cursor_attempt_id: null },
|
|
||||||
{
|
|
||||||
cursor_finished_at_ms: { [Op.not]: null },
|
|
||||||
cursor_attempt_id: { [Op.not]: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'local_artifact_cursor_pair_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 1 } },
|
|
||||||
name: 'local_artifact_cursor_version_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['updated_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { updated_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'local_artifact_cursor_updated_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import {
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
PROJECT_ROLES,
|
|
||||||
PROJECT_ROLE_BINDING_STATES,
|
|
||||||
PROJECT_STATUSES,
|
|
||||||
} from '../runtime/domain/projectPolicy';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const PROJECT_TABLE = 'Projects';
|
|
||||||
export const PROJECT_ROLE_BINDING_TABLE = 'ProjectRoleBindings';
|
|
||||||
export const PROJECT_SLUG_INDEX = 'projects_slug_uidx';
|
|
||||||
export const PROJECT_ROLE_BINDING_CURRENT_INDEX =
|
|
||||||
'project_role_binding_current_idx';
|
|
||||||
export const PROJECT_ROLE_BINDING_MUTATION_INDEX =
|
|
||||||
'project_role_binding_mutation_uidx';
|
|
||||||
export const PROJECT_ROLE_BINDING_SUBJECT_INDEX =
|
|
||||||
'project_role_binding_subject_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
tables: {
|
|
||||||
Projects: [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'slug',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'created_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
ProjectRoleBindings: [
|
|
||||||
'project_id',
|
|
||||||
'subject_type',
|
|
||||||
'subject_id',
|
|
||||||
'version',
|
|
||||||
'state',
|
|
||||||
'role',
|
|
||||||
'mutation_id',
|
|
||||||
'changed_by_type',
|
|
||||||
'changed_by_id',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
indexes: [
|
|
||||||
`${PROJECT_SLUG_INDEX}(slug) UNIQUE`,
|
|
||||||
`${PROJECT_ROLE_BINDING_CURRENT_INDEX}(project_id,subject_type,subject_id,version DESC)`,
|
|
||||||
`${PROJECT_ROLE_BINDING_MUTATION_INDEX}(project_id,mutation_id) UNIQUE`,
|
|
||||||
`${PROJECT_ROLE_BINDING_SUBJECT_INDEX}(subject_type,subject_id,project_id,version DESC)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'projects_status_check',
|
|
||||||
'projects_version_check',
|
|
||||||
'projects_created_at_check',
|
|
||||||
'projects_updated_at_check',
|
|
||||||
'project_role_bindings_subject_type_check',
|
|
||||||
'project_role_bindings_version_check',
|
|
||||||
'project_role_bindings_state_check',
|
|
||||||
'project_role_bindings_role_check',
|
|
||||||
'project_role_bindings_changed_by_type_check',
|
|
||||||
'project_role_bindings_created_at_check',
|
|
||||||
],
|
|
||||||
baseline: {
|
|
||||||
id: 'default',
|
|
||||||
name: 'Default',
|
|
||||||
slug: 'default',
|
|
||||||
status: 'active',
|
|
||||||
version: 1,
|
|
||||||
created_at_ms: 0,
|
|
||||||
updated_at_ms: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectPolicyManifest = manifest;
|
|
||||||
|
|
||||||
export const projectPolicyMigration: Migration = {
|
|
||||||
id: '0017-project-policy',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
PROJECT_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
|
||||||
name: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
slug: { type: DataTypes.STRING(128), allowNull: false },
|
|
||||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(PROJECT_TABLE, {
|
|
||||||
fields: ['status'],
|
|
||||||
type: 'check',
|
|
||||||
where: { status: { [Op.in]: PROJECT_STATUSES } },
|
|
||||||
name: 'projects_status_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
for (const [field, name, minimum] of [
|
|
||||||
['version', 'projects_version_check', 1],
|
|
||||||
['created_at_ms', 'projects_created_at_check', 0],
|
|
||||||
['updated_at_ms', 'projects_updated_at_check', 0],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(PROJECT_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.gte]: minimum } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addIndex(PROJECT_TABLE, ['slug'], {
|
|
||||||
name: PROJECT_SLUG_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.bulkInsert(PROJECT_TABLE, [manifest.baseline], {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.createTable(
|
|
||||||
PROJECT_ROLE_BINDING_TABLE,
|
|
||||||
{
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: PROJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
subject_type: {
|
|
||||||
type: DataTypes.STRING(32),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
subject_id: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
version: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
role: { type: DataTypes.STRING(16), allowNull: true },
|
|
||||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
changed_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
changed_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
for (const [field, values, name] of [
|
|
||||||
[
|
|
||||||
'subject_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'project_role_bindings_subject_type_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'state',
|
|
||||||
PROJECT_ROLE_BINDING_STATES,
|
|
||||||
'project_role_bindings_state_check',
|
|
||||||
],
|
|
||||||
['role', PROJECT_ROLES, 'project_role_bindings_role_check'],
|
|
||||||
[
|
|
||||||
'changed_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'project_role_bindings_changed_by_type_check',
|
|
||||||
],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.in]: values } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 1 } },
|
|
||||||
name: 'project_role_bindings_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'project_role_bindings_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(PROJECT_ROLE_BINDING_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'project_id',
|
|
||||||
'subject_type',
|
|
||||||
'subject_id',
|
|
||||||
{ name: 'version', order: 'DESC' },
|
|
||||||
],
|
|
||||||
name: PROJECT_ROLE_BINDING_CURRENT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
PROJECT_ROLE_BINDING_TABLE,
|
|
||||||
['project_id', 'mutation_id'],
|
|
||||||
{
|
|
||||||
name: PROJECT_ROLE_BINDING_MUTATION_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(PROJECT_ROLE_BINDING_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'subject_type',
|
|
||||||
'subject_id',
|
|
||||||
'project_id',
|
|
||||||
{ name: 'version', order: 'DESC' },
|
|
||||||
],
|
|
||||||
name: PROJECT_ROLE_BINDING_SUBJECT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
|
||||||
import { PROJECT_TABLE } from './0017-project-policy';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE =
|
|
||||||
'ProjectOwnerBootstrapChallenges';
|
|
||||||
export const PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX =
|
|
||||||
'project_owner_bootstrap_challenge_id_uidx';
|
|
||||||
export const PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX =
|
|
||||||
'project_owner_bootstrap_current_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
columns: [
|
|
||||||
'project_id',
|
|
||||||
'version',
|
|
||||||
'challenge_id',
|
|
||||||
'token_digest',
|
|
||||||
'issued_at_ms',
|
|
||||||
'expires_at_ms',
|
|
||||||
'consumed_at_ms',
|
|
||||||
'claimed_subject_type',
|
|
||||||
'claimed_subject_id',
|
|
||||||
],
|
|
||||||
indexes: [
|
|
||||||
`${PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX}(challenge_id) UNIQUE`,
|
|
||||||
`${PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX}(project_id,version DESC)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'project_owner_bootstrap_version_check',
|
|
||||||
'project_owner_bootstrap_issued_at_check',
|
|
||||||
'project_owner_bootstrap_lifetime_check',
|
|
||||||
'project_owner_bootstrap_claim_tuple_check',
|
|
||||||
'project_owner_bootstrap_claimed_subject_type_check',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectOwnerBootstrapManifest = manifest;
|
|
||||||
|
|
||||||
export const projectOwnerBootstrapMigration: Migration = {
|
|
||||||
id: '0018-project-owner-bootstrap',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: PROJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
version: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
challenge_id: { type: DataTypes.STRING(22), allowNull: false },
|
|
||||||
token_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
issued_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
consumed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
claimed_subject_type: { type: DataTypes.STRING(32), allowNull: true },
|
|
||||||
claimed_subject_id: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 1 } },
|
|
||||||
name: 'project_owner_bootstrap_version_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['issued_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { issued_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'project_owner_bootstrap_issued_at_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['expires_at_ms', 'issued_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { expires_at_ms: { [Op.gt]: { [Op.col]: 'issued_at_ms' } } },
|
|
||||||
name: 'project_owner_bootstrap_lifetime_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
fields: [
|
|
||||||
'consumed_at_ms',
|
|
||||||
'claimed_subject_type',
|
|
||||||
'claimed_subject_id',
|
|
||||||
],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
consumed_at_ms: null,
|
|
||||||
claimed_subject_type: null,
|
|
||||||
claimed_subject_id: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
consumed_at_ms: { [Op.not]: null },
|
|
||||||
claimed_subject_type: { [Op.not]: null },
|
|
||||||
claimed_subject_id: { [Op.not]: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'project_owner_bootstrap_claim_tuple_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['claimed_subject_type'],
|
|
||||||
type: 'check',
|
|
||||||
where: { claimed_subject_type: { [Op.in]: POLICY_SUBJECT_TYPES } },
|
|
||||||
name: 'project_owner_bootstrap_claimed_subject_type_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
||||||
['challenge_id'],
|
|
||||||
{
|
|
||||||
name: PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE, {
|
|
||||||
fields: ['project_id', { name: 'version', order: 'DESC' }],
|
|
||||||
name: PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import {
|
|
||||||
IDENTITY_AUTHENTICATION_BINDING_STATES,
|
|
||||||
IDENTITY_SUBJECT_STATUSES,
|
|
||||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
||||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
||||||
LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
||||||
} from '../runtime/domain/identityDirectory';
|
|
||||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const IDENTITY_SUBJECT_TABLE = 'IdentitySubjects';
|
|
||||||
export const IDENTITY_AUTHENTICATION_BINDING_TABLE =
|
|
||||||
'IdentityAuthenticationBindings';
|
|
||||||
export const IDENTITY_SUBJECT_STATUS_INDEX = 'identity_subject_status_idx';
|
|
||||||
export const IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX =
|
|
||||||
'identity_auth_binding_current_idx';
|
|
||||||
export const IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX =
|
|
||||||
'identity_auth_binding_subject_idx';
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
tables: {
|
|
||||||
IdentitySubjects: [
|
|
||||||
'id',
|
|
||||||
'type',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'created_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
],
|
|
||||||
IdentityAuthenticationBindings: [
|
|
||||||
'provider',
|
|
||||||
'provider_subject',
|
|
||||||
'version',
|
|
||||||
'state',
|
|
||||||
'subject_id',
|
|
||||||
'created_at_ms',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
indexes: [
|
|
||||||
`${IDENTITY_SUBJECT_STATUS_INDEX}(type,status,id)`,
|
|
||||||
`${IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX}(provider,provider_subject,version DESC)`,
|
|
||||||
`${IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX}(subject_id,provider,provider_subject,version DESC)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'identity_subject_type_check',
|
|
||||||
'identity_subject_status_check',
|
|
||||||
'identity_subject_version_check',
|
|
||||||
'identity_subject_created_at_check',
|
|
||||||
'identity_subject_updated_at_check',
|
|
||||||
'identity_auth_binding_version_check',
|
|
||||||
'identity_auth_binding_state_check',
|
|
||||||
'identity_auth_binding_created_at_check',
|
|
||||||
],
|
|
||||||
baseline: {
|
|
||||||
subject: {
|
|
||||||
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
||||||
type: 'user',
|
|
||||||
status: 'active',
|
|
||||||
version: 1,
|
|
||||||
created_at_ms: 0,
|
|
||||||
updated_at_ms: 0,
|
|
||||||
},
|
|
||||||
binding: {
|
|
||||||
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
|
|
||||||
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
|
|
||||||
version: 1,
|
|
||||||
state: 'active',
|
|
||||||
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
|
||||||
created_at_ms: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const identityDirectoryManifest = manifest;
|
|
||||||
|
|
||||||
export const identityDirectoryMigration: Migration = {
|
|
||||||
id: '0019-identity-directory',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
IDENTITY_SUBJECT_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(255), allowNull: false, primaryKey: true },
|
|
||||||
type: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
for (const [field, values, name] of [
|
|
||||||
['type', POLICY_SUBJECT_TYPES, 'identity_subject_type_check'],
|
|
||||||
['status', IDENTITY_SUBJECT_STATUSES, 'identity_subject_status_check'],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(IDENTITY_SUBJECT_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.in]: values } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const [field, name, minimum] of [
|
|
||||||
['version', 'identity_subject_version_check', 1],
|
|
||||||
['created_at_ms', 'identity_subject_created_at_check', 0],
|
|
||||||
['updated_at_ms', 'identity_subject_updated_at_check', 0],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(IDENTITY_SUBJECT_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.gte]: minimum } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addIndex(IDENTITY_SUBJECT_TABLE, {
|
|
||||||
fields: ['type', 'status', 'id'],
|
|
||||||
name: IDENTITY_SUBJECT_STATUS_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.bulkInsert(
|
|
||||||
IDENTITY_SUBJECT_TABLE,
|
|
||||||
[manifest.baseline.subject],
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryInterface.createTable(
|
|
||||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
|
||||||
{
|
|
||||||
provider: {
|
|
||||||
type: DataTypes.STRING(64),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
provider_subject: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
version: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
subject_id: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: IDENTITY_SUBJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'RESTRICT',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.gte]: 1 } },
|
|
||||||
name: 'identity_auth_binding_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
|
||||||
fields: ['state'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
state: { [Op.in]: IDENTITY_AUTHENTICATION_BINDING_STATES },
|
|
||||||
},
|
|
||||||
name: 'identity_auth_binding_state_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'identity_auth_binding_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'provider',
|
|
||||||
'provider_subject',
|
|
||||||
{ name: 'version', order: 'DESC' },
|
|
||||||
],
|
|
||||||
name: IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'subject_id',
|
|
||||||
'provider',
|
|
||||||
'provider_subject',
|
|
||||||
{ name: 'version', order: 'DESC' },
|
|
||||||
],
|
|
||||||
name: IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.bulkInsert(
|
|
||||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
|
||||||
[manifest.baseline.binding],
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import {
|
|
||||||
APPROVAL_DECISIONS,
|
|
||||||
APPROVAL_REQUEST_STATES,
|
|
||||||
APPROVAL_RISKS,
|
|
||||||
APPROVED_ACTION_DISPATCH_STATES,
|
|
||||||
} from '../runtime/domain/approvalRequest';
|
|
||||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
|
||||||
import { PROJECT_TABLE } from './0017-project-policy';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const APPROVAL_REQUEST_TABLE = 'ApprovalRequests';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_TABLE = 'ApprovedActionDispatches';
|
|
||||||
export const APPROVAL_REQUEST_DECISION_ID_INDEX =
|
|
||||||
'approval_request_decision_id_uidx';
|
|
||||||
export const APPROVAL_REQUEST_CONSUMPTION_ID_INDEX =
|
|
||||||
'approval_request_consumption_id_uidx';
|
|
||||||
export const APPROVAL_REQUEST_PENDING_INDEX = 'approval_request_pending_idx';
|
|
||||||
export const APPROVAL_REQUEST_REQUESTER_INDEX =
|
|
||||||
'approval_request_requester_idx';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_REQUEST_INDEX =
|
|
||||||
'approved_action_dispatch_request_uidx';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_PENDING_INDEX =
|
|
||||||
'approved_action_dispatch_pending_idx';
|
|
||||||
|
|
||||||
const approvalRequestColumns = [
|
|
||||||
'id',
|
|
||||||
'project_id',
|
|
||||||
'version',
|
|
||||||
'state',
|
|
||||||
'permission',
|
|
||||||
'action_type',
|
|
||||||
'action_ref',
|
|
||||||
'action_digest',
|
|
||||||
'preview_digest',
|
|
||||||
'risk',
|
|
||||||
'requested_by_type',
|
|
||||||
'requested_by_id',
|
|
||||||
'requested_at_ms',
|
|
||||||
'expires_at_ms',
|
|
||||||
'decision_id',
|
|
||||||
'decision',
|
|
||||||
'decision_reason_code',
|
|
||||||
'decided_by_type',
|
|
||||||
'decided_by_id',
|
|
||||||
'decided_at_ms',
|
|
||||||
'consumption_id',
|
|
||||||
'dispatch_id',
|
|
||||||
'consumed_by_type',
|
|
||||||
'consumed_by_id',
|
|
||||||
'consumed_at_ms',
|
|
||||||
];
|
|
||||||
|
|
||||||
const approvedActionDispatchColumns = [
|
|
||||||
'id',
|
|
||||||
'approval_request_id',
|
|
||||||
'approval_request_version',
|
|
||||||
'project_id',
|
|
||||||
'state',
|
|
||||||
'permission',
|
|
||||||
'action_type',
|
|
||||||
'action_ref',
|
|
||||||
'action_digest',
|
|
||||||
'preview_digest',
|
|
||||||
'requested_by_type',
|
|
||||||
'requested_by_id',
|
|
||||||
'consumed_by_type',
|
|
||||||
'consumed_by_id',
|
|
||||||
'created_at_ms',
|
|
||||||
];
|
|
||||||
|
|
||||||
const constraints = [
|
|
||||||
'approval_request_version_check',
|
|
||||||
'approval_request_state_check',
|
|
||||||
'approval_request_risk_check',
|
|
||||||
'approval_request_requester_type_check',
|
|
||||||
'approval_request_lifetime_check',
|
|
||||||
'approval_request_decision_tuple_check',
|
|
||||||
'approval_request_decision_value_check',
|
|
||||||
'approval_request_decided_by_type_check',
|
|
||||||
'approval_request_decision_time_check',
|
|
||||||
'approval_request_consumption_tuple_check',
|
|
||||||
'approval_request_consumed_by_type_check',
|
|
||||||
'approval_request_consumption_time_check',
|
|
||||||
'approval_request_state_tuple_check',
|
|
||||||
'approved_action_dispatch_version_check',
|
|
||||||
'approved_action_dispatch_state_check',
|
|
||||||
'approved_action_dispatch_requester_type_check',
|
|
||||||
'approved_action_dispatch_consumer_type_check',
|
|
||||||
'approved_action_dispatch_created_at_check',
|
|
||||||
];
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
tables: {
|
|
||||||
ApprovalRequests: approvalRequestColumns,
|
|
||||||
ApprovedActionDispatches: approvedActionDispatchColumns,
|
|
||||||
},
|
|
||||||
indexes: [
|
|
||||||
`${APPROVAL_REQUEST_DECISION_ID_INDEX}(decision_id) UNIQUE`,
|
|
||||||
`${APPROVAL_REQUEST_CONSUMPTION_ID_INDEX}(consumption_id) UNIQUE`,
|
|
||||||
`${APPROVAL_REQUEST_PENDING_INDEX}(project_id,state,expires_at_ms,id)`,
|
|
||||||
`${APPROVAL_REQUEST_REQUESTER_INDEX}(project_id,requested_by_type,requested_by_id,requested_at_ms DESC,id)`,
|
|
||||||
`${APPROVED_ACTION_DISPATCH_REQUEST_INDEX}(approval_request_id) UNIQUE`,
|
|
||||||
`${APPROVED_ACTION_DISPATCH_PENDING_INDEX}(project_id,state,created_at_ms,id)`,
|
|
||||||
],
|
|
||||||
constraints,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const approvalRequestManifest = manifest;
|
|
||||||
|
|
||||||
function nullableTuple(...fields: string[]) {
|
|
||||||
return {
|
|
||||||
[Op.or]: [
|
|
||||||
Object.fromEntries(fields.map((field) => [field, null])),
|
|
||||||
Object.fromEntries(fields.map((field) => [field, { [Op.not]: null }])),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const approvalRequestMigration: Migration = {
|
|
||||||
id: '0020-approval-requests',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
APPROVAL_REQUEST_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: PROJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
action_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
action_ref: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
action_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
preview_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
risk: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
requested_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
requested_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
requested_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
decision_id: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
decision: { type: DataTypes.STRING(16), allowNull: true },
|
|
||||||
decision_reason_code: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
decided_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
|
||||||
decided_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
decided_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
consumption_id: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
dispatch_id: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
consumed_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
|
||||||
consumed_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
consumed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { version: { [Op.between]: [1, 3] } },
|
|
||||||
name: 'approval_request_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
for (const [field, values, name] of [
|
|
||||||
['state', APPROVAL_REQUEST_STATES, 'approval_request_state_check'],
|
|
||||||
['risk', APPROVAL_RISKS, 'approval_request_risk_check'],
|
|
||||||
[
|
|
||||||
'requested_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'approval_request_requester_type_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'decided_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'approval_request_decided_by_type_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'consumed_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'approval_request_consumed_by_type_check',
|
|
||||||
],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.in]: values } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['requested_at_ms', 'expires_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
requested_at_ms: { [Op.gte]: 0 },
|
|
||||||
expires_at_ms: { [Op.gt]: { [Op.col]: 'requested_at_ms' } },
|
|
||||||
},
|
|
||||||
name: 'approval_request_lifetime_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'decision_id',
|
|
||||||
'decision',
|
|
||||||
'decision_reason_code',
|
|
||||||
'decided_by_type',
|
|
||||||
'decided_by_id',
|
|
||||||
'decided_at_ms',
|
|
||||||
],
|
|
||||||
type: 'check',
|
|
||||||
where: nullableTuple(
|
|
||||||
'decision_id',
|
|
||||||
'decision',
|
|
||||||
'decision_reason_code',
|
|
||||||
'decided_by_type',
|
|
||||||
'decided_by_id',
|
|
||||||
'decided_at_ms',
|
|
||||||
),
|
|
||||||
name: 'approval_request_decision_tuple_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['decision'],
|
|
||||||
type: 'check',
|
|
||||||
where: { decision: { [Op.in]: APPROVAL_DECISIONS } },
|
|
||||||
name: 'approval_request_decision_value_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['decided_at_ms', 'requested_at_ms', 'expires_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{ decided_at_ms: null },
|
|
||||||
{
|
|
||||||
decided_at_ms: {
|
|
||||||
[Op.gte]: { [Op.col]: 'requested_at_ms' },
|
|
||||||
[Op.lt]: { [Op.col]: 'expires_at_ms' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'approval_request_decision_time_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'consumption_id',
|
|
||||||
'dispatch_id',
|
|
||||||
'consumed_by_type',
|
|
||||||
'consumed_by_id',
|
|
||||||
'consumed_at_ms',
|
|
||||||
],
|
|
||||||
type: 'check',
|
|
||||||
where: nullableTuple(
|
|
||||||
'consumption_id',
|
|
||||||
'dispatch_id',
|
|
||||||
'consumed_by_type',
|
|
||||||
'consumed_by_id',
|
|
||||||
'consumed_at_ms',
|
|
||||||
),
|
|
||||||
name: 'approval_request_consumption_tuple_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['consumed_at_ms', 'decided_at_ms', 'expires_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{ consumed_at_ms: null },
|
|
||||||
{
|
|
||||||
consumed_at_ms: {
|
|
||||||
[Op.gte]: { [Op.col]: 'decided_at_ms' },
|
|
||||||
[Op.lt]: { [Op.col]: 'expires_at_ms' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'approval_request_consumption_time_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['state', 'version', 'decision', 'decision_id', 'consumption_id'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
state: 'pending',
|
|
||||||
version: 1,
|
|
||||||
decision_id: null,
|
|
||||||
consumption_id: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: 'approved',
|
|
||||||
version: 2,
|
|
||||||
decision: 'approved',
|
|
||||||
consumption_id: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: 'rejected',
|
|
||||||
version: 2,
|
|
||||||
decision: 'rejected',
|
|
||||||
consumption_id: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: 'consumed',
|
|
||||||
version: 3,
|
|
||||||
decision: 'approved',
|
|
||||||
consumption_id: { [Op.not]: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'approval_request_state_tuple_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, ['decision_id'], {
|
|
||||||
name: APPROVAL_REQUEST_DECISION_ID_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, ['consumption_id'], {
|
|
||||||
name: APPROVAL_REQUEST_CONSUMPTION_ID_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: ['project_id', 'state', 'expires_at_ms', 'id'],
|
|
||||||
name: APPROVAL_REQUEST_PENDING_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, {
|
|
||||||
fields: [
|
|
||||||
'project_id',
|
|
||||||
'requested_by_type',
|
|
||||||
'requested_by_id',
|
|
||||||
{ name: 'requested_at_ms', order: 'DESC' },
|
|
||||||
'id',
|
|
||||||
],
|
|
||||||
name: APPROVAL_REQUEST_REQUESTER_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.createTable(
|
|
||||||
APPROVED_ACTION_DISPATCH_TABLE,
|
|
||||||
{
|
|
||||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
|
||||||
approval_request_id: {
|
|
||||||
type: DataTypes.STRING(64),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: APPROVAL_REQUEST_TABLE, key: 'id' },
|
|
||||||
onDelete: 'RESTRICT',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
approval_request_version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: PROJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
action_type: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
action_ref: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
action_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
preview_digest: { type: DataTypes.STRING(64), allowNull: false },
|
|
||||||
requested_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
requested_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
consumed_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
|
||||||
consumed_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
|
||||||
fields: ['approval_request_version'],
|
|
||||||
type: 'check',
|
|
||||||
where: { approval_request_version: 3 },
|
|
||||||
name: 'approved_action_dispatch_version_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
for (const [field, values, name] of [
|
|
||||||
[
|
|
||||||
'state',
|
|
||||||
APPROVED_ACTION_DISPATCH_STATES,
|
|
||||||
'approved_action_dispatch_state_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'requested_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'approved_action_dispatch_requester_type_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'consumed_by_type',
|
|
||||||
POLICY_SUBJECT_TYPES,
|
|
||||||
'approved_action_dispatch_consumer_type_check',
|
|
||||||
],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.in]: values } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'approved_action_dispatch_created_at_check',
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
APPROVED_ACTION_DISPATCH_TABLE,
|
|
||||||
['approval_request_id'],
|
|
||||||
{
|
|
||||||
name: APPROVED_ACTION_DISPATCH_REQUEST_INDEX,
|
|
||||||
unique: true,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_TABLE, {
|
|
||||||
fields: ['project_id', 'state', 'created_at_ms', 'id'],
|
|
||||||
name: APPROVED_ACTION_DISPATCH_PENDING_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
import { DataTypes, Op } from 'sequelize';
|
|
||||||
import {
|
|
||||||
APPROVED_ACTION_EXECUTION_STATUSES,
|
|
||||||
DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
|
|
||||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
|
||||||
} from '../runtime/domain/approvedActionDispatchExecution';
|
|
||||||
import { PROJECT_TABLE } from './0017-project-policy';
|
|
||||||
import { APPROVED_ACTION_DISPATCH_TABLE } from './0020-approval-requests';
|
|
||||||
import type { Migration } from './types';
|
|
||||||
|
|
||||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_TABLE =
|
|
||||||
'ApprovedActionDispatchExecutions';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX =
|
|
||||||
'approved_action_execution_due_idx';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX =
|
|
||||||
'approved_action_execution_project_idx';
|
|
||||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX =
|
|
||||||
'approved_action_execution_lease_idx';
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
'dispatch_id',
|
|
||||||
'project_id',
|
|
||||||
'status',
|
|
||||||
'version',
|
|
||||||
'attempt_count',
|
|
||||||
'max_attempts',
|
|
||||||
'eligible_at_ms',
|
|
||||||
'next_attempt_at_ms',
|
|
||||||
'lease_owner',
|
|
||||||
'lease_token',
|
|
||||||
'lease_expires_at_ms',
|
|
||||||
'started_at_ms',
|
|
||||||
'result_mutation_id',
|
|
||||||
'last_result_code',
|
|
||||||
'completed_at_ms',
|
|
||||||
'created_at_ms',
|
|
||||||
'updated_at_ms',
|
|
||||||
];
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
table: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
columns,
|
|
||||||
indexes: [
|
|
||||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX}(eligible_at_ms,dispatch_id)`,
|
|
||||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
|
|
||||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
|
|
||||||
],
|
|
||||||
constraints: [
|
|
||||||
'approved_action_execution_status_check',
|
|
||||||
'approved_action_execution_version_check',
|
|
||||||
'approved_action_execution_attempt_count_check',
|
|
||||||
'approved_action_execution_max_attempts_check',
|
|
||||||
'approved_action_execution_attempt_budget_check',
|
|
||||||
'approved_action_execution_lease_tuple_check',
|
|
||||||
'approved_action_execution_created_at_check',
|
|
||||||
'approved_action_execution_updated_at_check',
|
|
||||||
],
|
|
||||||
baselineMaxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const approvedActionDispatchExecutionManifest = manifest;
|
|
||||||
|
|
||||||
export const approvedActionDispatchExecutionMigration: Migration = {
|
|
||||||
id: '0021-approved-action-dispatch-executions',
|
|
||||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
|
||||||
async up({ queryInterface, transaction }) {
|
|
||||||
await queryInterface.createTable(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
dispatch_id: {
|
|
||||||
type: DataTypes.STRING(64),
|
|
||||||
allowNull: false,
|
|
||||||
primaryKey: true,
|
|
||||||
references: { model: APPROVED_ACTION_DISPATCH_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
project_id: {
|
|
||||||
type: DataTypes.STRING(128),
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: PROJECT_TABLE, key: 'id' },
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
attempt_count: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
max_attempts: { type: DataTypes.INTEGER, allowNull: false },
|
|
||||||
eligible_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
|
||||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
result_mutation_id: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
|
|
||||||
completed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
|
||||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['status'],
|
|
||||||
type: 'check',
|
|
||||||
where: { status: { [Op.in]: APPROVED_ACTION_EXECUTION_STATUSES } },
|
|
||||||
name: 'approved_action_execution_status_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for (const [field, minimum, maximum, name] of [
|
|
||||||
['version', 0, 2_147_483_647, 'approved_action_execution_version_check'],
|
|
||||||
[
|
|
||||||
'attempt_count',
|
|
||||||
0,
|
|
||||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
|
||||||
'approved_action_execution_attempt_count_check',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'max_attempts',
|
|
||||||
1,
|
|
||||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
|
||||||
'approved_action_execution_max_attempts_check',
|
|
||||||
],
|
|
||||||
] as const) {
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: [field],
|
|
||||||
type: 'check',
|
|
||||||
where: { [field]: { [Op.between]: [minimum, maximum] } },
|
|
||||||
name,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['attempt_count', 'max_attempts'],
|
|
||||||
type: 'check',
|
|
||||||
where: { attempt_count: { [Op.lte]: { [Op.col]: 'max_attempts' } } },
|
|
||||||
name: 'approved_action_execution_attempt_budget_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['lease_owner', 'lease_token', 'lease_expires_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
lease_owner: null,
|
|
||||||
lease_token: null,
|
|
||||||
lease_expires_at_ms: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lease_owner: { [Op.not]: null },
|
|
||||||
lease_token: { [Op.not]: null },
|
|
||||||
lease_expires_at_ms: { [Op.not]: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
name: 'approved_action_execution_lease_tuple_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
|
||||||
name: 'approved_action_execution_created_at_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addConstraint(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
{
|
|
||||||
fields: ['updated_at_ms', 'created_at_ms'],
|
|
||||||
type: 'check',
|
|
||||||
where: { updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } } },
|
|
||||||
name: 'approved_action_execution_updated_at_check',
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(
|
|
||||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
|
||||||
['eligible_at_ms', 'dispatch_id'],
|
|
||||||
{
|
|
||||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
|
|
||||||
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
|
|
||||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
|
|
||||||
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
|
|
||||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
await queryInterface.sequelize.query(
|
|
||||||
`INSERT INTO "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
|
||||||
(dispatch_id, project_id, status, version, attempt_count, max_attempts,
|
|
||||||
eligible_at_ms, next_attempt_at_ms, lease_owner, lease_token,
|
|
||||||
lease_expires_at_ms, started_at_ms, result_mutation_id,
|
|
||||||
last_result_code, completed_at_ms, created_at_ms, updated_at_ms)
|
|
||||||
SELECT id, project_id, 'pending', 0, 0, :maxAttempts,
|
|
||||||
created_at_ms, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
||||||
created_at_ms, created_at_ms
|
|
||||||
FROM "${APPROVED_ACTION_DISPATCH_TABLE}"`,
|
|
||||||
{
|
|
||||||
replacements: { maxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS },
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user