mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_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'
|
||||
|
||||
JWT_SECRET=
|
||||
@@ -14,3 +21,6 @@ JWT_EXPIRES_IN=
|
||||
QINIU_AK=
|
||||
QINIU_SK=
|
||||
QINIU_SCOPE=
|
||||
|
||||
# Python 虚拟环境路径,留空则使用系统 Python
|
||||
# PYTHON_VENV_DIR=./.venv
|
||||
|
||||
@@ -2,20 +2,56 @@ name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
paths:
|
||||
- "**"
|
||||
- "!*.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:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
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:
|
||||
contents: read
|
||||
|
||||
# Keep master image tags and their bundle indexes in one serialized release.
|
||||
# A rerun may intentionally replace a version, including from the same commit.
|
||||
# Develop image builds and their shared bundle index serialize separately.
|
||||
# Git-tag builds and static synchronization remain independent.
|
||||
concurrency:
|
||||
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
code_gitlab:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -32,37 +68,9 @@ jobs:
|
||||
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"
|
||||
|
||||
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:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -72,6 +80,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -80,82 +89,165 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
pnpm build:info
|
||||
|
||||
- name: Pack the npm panel archive from the verified build
|
||||
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||
|
||||
- uses: actions/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
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
mkdir -p tmp
|
||||
cd ./tmp
|
||||
cp -rf ../static/* ./
|
||||
git init -b ${GITHUB_BRANCH} && git add .
|
||||
git config --local user.name 'github-actions[bot]'
|
||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
- name: publish verified upgrade download sizes
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: static-gitlab-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: |
|
||||
set +e
|
||||
set -euo pipefail
|
||||
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
|
||||
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
|
||||
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"
|
||||
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||
|
||||
static_gitee:
|
||||
verify-gitee-mirrors:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repository: [qinglong, qinglong-static]
|
||||
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:
|
||||
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: |
|
||||
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 clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||
cd static-mirror
|
||||
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
||||
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"
|
||||
- name: Notify and wait for Gitee static mirror
|
||||
if: ${{ matrix.repository == 'qinglong-static' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
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
|
||||
echo "::notice::Gitee static push 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-static","private":"false"}' \
|
||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
||||
fi
|
||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
||||
sync-existing-static:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mirror: [gitlab]
|
||||
concurrency:
|
||||
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- 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:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -205,10 +297,11 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Alpine)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -216,6 +309,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
||||
@@ -224,17 +319,24 @@ jobs:
|
||||
build-debian:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -283,10 +385,11 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Debian)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -295,6 +398,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||
@@ -309,11 +414,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -351,7 +461,7 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -359,6 +469,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.310
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: |
|
||||
whyour/qinglong:python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||
@@ -374,11 +486,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -416,7 +533,7 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -425,33 +542,202 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian310
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: |
|
||||
whyour/qinglong:debian-python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||
|
||||
publish:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: [build-alpine, build-debian]
|
||||
# One unmodified amd64 package for migration acceptance only. This does not
|
||||
# publish bundles or advance development/formal channel indexes.
|
||||
build-npm-migration-bundle:
|
||||
needs: build-debian
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Export the official platform package from this run's image digest
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
set -euo pipefail
|
||||
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
|
||||
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: publish npm package
|
||||
verify-npm-package:
|
||||
needs: [build-static, build-npm-migration-bundle]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['20.18.1', '22.17.0', '24']
|
||||
container: node:${{ matrix.node }}-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: qinglong-npm-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||
- name: Install isolated service test tools
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
npm publish
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||
# The published legacy launcher resets PATH to standard directories.
|
||||
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||
# remains private to each verification run.
|
||||
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||
- name: Install and upgrade the exact npm archive
|
||||
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}"
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||
path: output/upgrade/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish-upgrade-bundles:
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: build-upgrade-bundles
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: upgrade-channel-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- run: pnpm install --prod --frozen-lockfile
|
||||
- run: npm --prefix cli ci
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: upgrade-*-${{ github.sha }}
|
||||
path: output/upgrade
|
||||
merge-multiple: true
|
||||
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||
- name: Publish complete packages before advancing the channel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||
|
||||
@@ -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,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
|
||||
/node_modules
|
||||
/packages/*/node_modules
|
||||
/packages/*/dist
|
||||
/npm-debug.log*
|
||||
/yarn-error.log
|
||||
/yarn.lock
|
||||
@@ -11,7 +9,6 @@
|
||||
|
||||
# production
|
||||
/static
|
||||
/.ql3-panel-dist
|
||||
/data
|
||||
|
||||
# misc
|
||||
@@ -32,9 +29,22 @@ __pycache__
|
||||
/shell/preload/*-notify.json
|
||||
/shell/preload/__ql_notify__.*
|
||||
/shell/preload/lang_env.sh
|
||||
/shell/upgrade-lock.yaml
|
||||
|
||||
.deepseek/
|
||||
.claude/
|
||||
|
||||
# local Kubernetes overlays
|
||||
/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
|
||||
resolve-peers-from-workspace-root=false
|
||||
strict-peer-dependencies=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 dark mode
|
||||
- Support cell phone operation
|
||||
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
|
||||
|
||||
## Version
|
||||
|
||||
@@ -79,6 +80,29 @@ npm i @whyour/qinglong
|
||||
|
||||
[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
|
||||
|
||||
```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)
|
||||
|
||||
## 远程 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
|
||||
|
||||
@@ -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 * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { join, basename } from 'path';
|
||||
import { SAMPLE_FILES } from '../config/const';
|
||||
import { t } from '../shared/i18n';
|
||||
import ConfigService from '../services/config';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -72,20 +72,15 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const { name, content } = req.body;
|
||||
// Resolve path first to prevent traversal attacks
|
||||
let basePath = config.configPath;
|
||||
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 resolvedPath = join(basePath, cleanName);
|
||||
const normalized = join(resolvedPath);
|
||||
// Verify the resolved path stays within allowed directory
|
||||
if (!normalized.startsWith(basePath)) {
|
||||
return res.send({ code: 403, message: t('文件路径无效') });
|
||||
}
|
||||
// Check blacklist on actual filename (not user input)
|
||||
if (config.blackFileList.includes(basename(normalized))) {
|
||||
const normalized = resolveFileAccess(
|
||||
config.configPath,
|
||||
[name],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!normalized) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(normalized, content);
|
||||
@@ -96,15 +91,10 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configService = Container.get(ConfigService);
|
||||
await configService.getFile(req.params.file, res);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+44
-12
@@ -7,12 +7,35 @@ import { celebrate, Joi } from 'celebrate';
|
||||
import { commonCronSchema } from '../validation/schedule';
|
||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import { t } from '../shared/i18n';
|
||||
import cronClient from '../schedule/client';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
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(
|
||||
'/views',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -304,16 +327,34 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
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) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
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({
|
||||
code: 200,
|
||||
data: result.content,
|
||||
logStatus: result.status,
|
||||
offset: result.offset,
|
||||
nextOffset: result.nextOffset,
|
||||
total: result.total,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -436,11 +477,6 @@ export default (app: Router) => {
|
||||
last_running_time: Joi.number().optional().allow(null),
|
||||
last_execution_time: 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) => {
|
||||
@@ -467,12 +503,8 @@ export default (app: Router) => {
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const instances = await RunningInstanceModel.findAll({
|
||||
where: {
|
||||
cron_id: req.params.id,
|
||||
},
|
||||
order: [['started_at', 'DESC']],
|
||||
raw: true,
|
||||
const instances = await RunningInstanceModel.find({
|
||||
cron_id: req.params.id,
|
||||
});
|
||||
return res.send({ code: 200, data: instances });
|
||||
} catch (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 { Container } from 'typedi';
|
||||
import { fn, col, where, Op } from 'sequelize';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import os from 'os';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/dashboard', route);
|
||||
|
||||
route.post('/record', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { ref_id, code, elapsed } = req.body;
|
||||
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isSuccess = code === 0 ? 1 : 0;
|
||||
const isFail = code !== 0 ? 1 : 0;
|
||||
const elapsedMs = (Number(elapsed) || 0) * 1000;
|
||||
|
||||
const existing = await CrontabStatModel.findOne({
|
||||
where: { ref_id: Number(ref_id), date: today },
|
||||
});
|
||||
|
||||
const existing = await CrontabStatModel.findOne(
|
||||
{ ref_id: Number(ref_id), date: today },
|
||||
{},
|
||||
);
|
||||
if (existing) {
|
||||
await CrontabStatModel.update(
|
||||
await CrontabStatModel.nativeUpdate(
|
||||
{ id: existing.id },
|
||||
{
|
||||
run_count: (existing.run_count || 0) + 1,
|
||||
success_count: (existing.success_count || 0) + isSuccess,
|
||||
@@ -40,10 +34,9 @@ export default (app: Router) => {
|
||||
total_time: (existing.total_time || 0) + elapsedMs,
|
||||
max_time: Math.max(existing.max_time || 0, elapsedMs),
|
||||
},
|
||||
{ where: { id: existing.id } },
|
||||
);
|
||||
} else {
|
||||
await CrontabStatModel.create({
|
||||
await CrontabStatModel.insert({
|
||||
ref_id: Number(ref_id),
|
||||
date: today,
|
||||
run_count: 1,
|
||||
@@ -53,41 +46,27 @@ export default (app: Router) => {
|
||||
max_time: elapsedMs,
|
||||
});
|
||||
}
|
||||
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
res.send({ code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/overview',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [total, enabled, disabled, stats] = await Promise.all([
|
||||
CrontabModel.count(),
|
||||
CrontabModel.count({ where: { isDisabled: 0 } }),
|
||||
CrontabModel.count({ where: { isDisabled: 1 } }),
|
||||
CrontabStatModel.findOne({
|
||||
attributes: [
|
||||
[fn('SUM', col('run_count')), 'total_runs'],
|
||||
[fn('SUM', col('success_count')), 'total_success'],
|
||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
],
|
||||
where: { date: today },
|
||||
raw: true,
|
||||
}),
|
||||
CrontabModel.count({}),
|
||||
CrontabModel.count({ isDisabled: 0 }),
|
||||
CrontabModel.count({ isDisabled: 1 }),
|
||||
statistics.totals(today),
|
||||
]);
|
||||
|
||||
const row = stats as any;
|
||||
const totalRuns = Number(row?.total_runs) || 0;
|
||||
const totalSuccess = Number(row?.total_success) || 0;
|
||||
const totalFail = Number(row?.total_fail) || 0;
|
||||
const totalTime = Number(row?.total_time) || 0;
|
||||
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
@@ -97,7 +76,10 @@ export default (app: Router) => {
|
||||
todayRuns: totalRuns,
|
||||
todaySuccess: totalSuccess,
|
||||
todayFail: totalFail,
|
||||
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
|
||||
successRate:
|
||||
totalRuns > 0
|
||||
? ((totalSuccess / totalRuns) * 100).toFixed(1)
|
||||
: '0',
|
||||
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
|
||||
},
|
||||
});
|
||||
@@ -106,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(
|
||||
'/trend',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -116,22 +139,7 @@ export default (app: Router) => {
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
|
||||
}
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'date',
|
||||
[fn('SUM', col('run_count')), 'total_runs'],
|
||||
[fn('SUM', col('success_count')), 'total_success'],
|
||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||
],
|
||||
where: {
|
||||
date: { [Op.in]: dates },
|
||||
},
|
||||
group: ['date'],
|
||||
order: [['date', 'ASC']],
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const rows = (await statistics.trend(dates)) as any[];
|
||||
const dataMap: Record<string, any> = {};
|
||||
rows.forEach((r: any) => {
|
||||
dataMap[r.date] = {
|
||||
@@ -140,89 +148,52 @@ export default (app: Router) => {
|
||||
fail: Number(r.total_fail) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const data = dates.map((d) => ({
|
||||
date: dayjs(d).format('MM-DD'),
|
||||
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/top-time',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'ref_id',
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
[fn('SUM', col('run_count')), 'run_count'],
|
||||
[fn('MAX', col('max_time')), 'max_time'],
|
||||
],
|
||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col('total_time')), 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const rows = (await statistics.ranking(today, 'total_time')) as any[];
|
||||
const ids = rows.map((r) => Number(r.ref_id));
|
||||
const crons = await CrontabModel.findAll({
|
||||
where: { id: { [Op.in]: ids } },
|
||||
raw: true,
|
||||
});
|
||||
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
|
||||
const nameMap: Record<number, string> = {};
|
||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||
|
||||
crons.forEach((c: any) => {
|
||||
nameMap[c.id] = c.name || c.command;
|
||||
});
|
||||
const data = rows.map((r: any, i) => ({
|
||||
rank: i + 1,
|
||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||
maxTime: Number(r.max_time),
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/top-count',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'ref_id',
|
||||
[fn('SUM', col('run_count')), 'run_count'],
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
[fn('SUM', col('success_count')), 'success_count'],
|
||||
],
|
||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col('run_count')), 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const rows = (await statistics.ranking(today, 'run_count')) as any[];
|
||||
const ids = rows.map((r) => Number(r.ref_id));
|
||||
const crons = await CrontabModel.findAll({
|
||||
where: { id: { [Op.in]: ids } },
|
||||
raw: true,
|
||||
});
|
||||
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
|
||||
const nameMap: Record<number, string> = {};
|
||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||
|
||||
crons.forEach((c: any) => {
|
||||
nameMap[c.id] = c.name || c.command;
|
||||
});
|
||||
const data = rows.map((r: any, i) => ({
|
||||
rank: i + 1,
|
||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||
@@ -230,48 +201,42 @@ export default (app: Router) => {
|
||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||
successRate:
|
||||
Number(r.run_count) > 0
|
||||
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
|
||||
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(
|
||||
1,
|
||||
)
|
||||
: '0',
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/runtime',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const runningInstances = await RunningInstanceModel.findAll({
|
||||
where: {
|
||||
const runningInstances = await RunningInstanceModel.find(
|
||||
{
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const queuedCrons = await CrontabModel.findAll({
|
||||
where: {
|
||||
{},
|
||||
);
|
||||
const queuedCrons = await CrontabModel.find(
|
||||
{
|
||||
status: 3, // queued
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
|
||||
{},
|
||||
);
|
||||
// Fetch cron names for running instances
|
||||
const cronIds = [
|
||||
...new Set(runningInstances.map((i: any) => i.cron_id)),
|
||||
];
|
||||
const crons =
|
||||
cronIds.length > 0
|
||||
? await CrontabModel.findAll({
|
||||
where: { id: cronIds },
|
||||
raw: true,
|
||||
})
|
||||
? await CrontabModel.find({ id: cronIds }, {})
|
||||
: [];
|
||||
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
|
||||
|
||||
const now = dayjs().unix();
|
||||
const running = runningInstances.map((inst: any) => {
|
||||
const cron = cronMap.get(inst.cron_id);
|
||||
@@ -284,19 +249,18 @@ export default (app: Router) => {
|
||||
logPath: inst.log_path,
|
||||
};
|
||||
});
|
||||
|
||||
const dayAgo = dayjs().subtract(24, 'hour').unix();
|
||||
const idleTasks = await CrontabModel.findAll({
|
||||
where: {
|
||||
const idleTasks = await CrontabModel.find(
|
||||
{
|
||||
isDisabled: 0,
|
||||
status: 1,
|
||||
last_execution_time: { [Op.lt]: dayAgo },
|
||||
last_execution_time: { ['$lt']: dayAgo },
|
||||
},
|
||||
order: [['last_execution_time', 'ASC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
{
|
||||
orderBy: orderBy([['last_execution_time', 'ASC']]),
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
@@ -317,33 +281,45 @@ export default (app: Router) => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/labels',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const [crons, stats] = (await Promise.all([
|
||||
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
|
||||
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
|
||||
]));
|
||||
|
||||
const [crons, stats] = await Promise.all([
|
||||
CrontabModel.find({ isDisabled: 0 }, {}),
|
||||
CrontabStatModel.find({ date: today }, {}),
|
||||
]);
|
||||
const statMap: Record<number, any> = {};
|
||||
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
|
||||
|
||||
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
|
||||
stats.forEach((s: any) => {
|
||||
statMap[s.ref_id] = s;
|
||||
});
|
||||
const labelMap: Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
runs: number;
|
||||
success: number;
|
||||
totalTime: number;
|
||||
}
|
||||
> = {};
|
||||
crons.forEach((c) => {
|
||||
let rawLabels = c.labels;
|
||||
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
|
||||
const labels: string[] = Array.isArray(rawLabels)
|
||||
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
|
||||
? [
|
||||
...new Set(
|
||||
(rawLabels as string[]).filter((l: string) => !isEmpty(l)),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
if (labels.length === 0) {
|
||||
labels.push(t('未分类'));
|
||||
}
|
||||
const st = statMap[c.id!];
|
||||
labels.forEach((label: string) => {
|
||||
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
||||
if (!labelMap[label])
|
||||
labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
||||
labelMap[label].count += 1;
|
||||
if (st) {
|
||||
labelMap[label].runs += Number(st.run_count) || 0;
|
||||
@@ -352,24 +328,22 @@ export default (app: Router) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const data = Object.entries(labelMap)
|
||||
.map(([label, v]) => ({
|
||||
label,
|
||||
count: v.count,
|
||||
todayRuns: v.runs,
|
||||
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
||||
successRate:
|
||||
v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
||||
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.todayRuns - a.todayRuns);
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/system',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -382,7 +356,9 @@ export default (app: Router) => {
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memTotal: os.totalmem(),
|
||||
memFree: os.freemem(),
|
||||
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
|
||||
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(
|
||||
1,
|
||||
),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
|
||||
|
||||
+17
-14
@@ -1,24 +1,16 @@
|
||||
import { Joi, celebrate } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import EnvService from '../services/env';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
},
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
const labelSchema = Joi.array()
|
||||
.items(Joi.string().trim().required())
|
||||
.min(1)
|
||||
@@ -282,12 +274,23 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
|
||||
const parseContent = safeJSONParse(fileContent);
|
||||
let parseContent: unknown;
|
||||
try {
|
||||
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
|
||||
} catch {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
const data = Array.isArray(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(
|
||||
data.map((x) => ({
|
||||
name: x.name,
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ export default (app: Router) => {
|
||||
try {
|
||||
const healthService = Container.get(HealthService);
|
||||
const health = await healthService.check();
|
||||
res.status(200).send({
|
||||
code: 200,
|
||||
res.status(health.status === 'ok' ? 200 : 503).send({
|
||||
code: health.status === 'ok' ? 200 : 503,
|
||||
data: health,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -28,6 +30,8 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
+30
-21
@@ -5,13 +5,13 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
getFileContentByName,
|
||||
readDirs,
|
||||
removeAnsi,
|
||||
rmPath,
|
||||
} from '../config/util';
|
||||
import LogService from '../services/log';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
|
||||
const route = Router();
|
||||
const blacklist = ['.tmp'];
|
||||
|
||||
@@ -34,6 +34,20 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/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) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
@@ -52,11 +66,19 @@ export default (app: Router) => {
|
||||
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({
|
||||
code: 200,
|
||||
data: removeAnsi(content),
|
||||
data: removeAnsi(chunk.content),
|
||||
logStatus: runningInstance ? 'running' : undefined,
|
||||
offset: chunk.offset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
total: chunk.total,
|
||||
truncated: chunk.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -66,24 +88,11 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(
|
||||
(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);
|
||||
}
|
||||
(req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
||||
import OpenService from '../services/open';
|
||||
import { Logger } from 'winston';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -100,6 +101,7 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/auth/token',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
|
||||
celebrate({
|
||||
query: {
|
||||
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 {
|
||||
fileExist,
|
||||
isPathInside,
|
||||
readDirs,
|
||||
readDir,
|
||||
rmPath,
|
||||
IFile,
|
||||
} from '../config/util';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
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 ScriptHistoryService from '../services/scriptHistory';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
ScriptHistoryError,
|
||||
HistoryUnavailableError,
|
||||
} from '../shared/scriptHistory';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((rootPath) =>
|
||||
isPathInside(rootPath, resolved),
|
||||
return config.writePathList.some((x) =>
|
||||
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({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
cb(null, config.tmpPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
cb(null, randomUUID());
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
@@ -59,6 +96,11 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
@@ -86,7 +128,8 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
@@ -100,7 +143,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query?.path as string || '',
|
||||
(req.query?.path as string) || '',
|
||||
req.query.file as string,
|
||||
);
|
||||
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(
|
||||
'/: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({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query?.path as string || '',
|
||||
req.params.file,
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.detail(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
req.query.id as string,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.put(
|
||||
'/history/restore',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
expectedHash: Joi.string().hex().length(64).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { path = '', filename, id, expectedHash } = req.body;
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.restore(path, filename, id, expectedHash);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||
});
|
||||
});
|
||||
|
||||
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'),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: 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(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
@@ -167,11 +280,7 @@ export default (app: Router) => {
|
||||
if (!path.startsWith('/')) {
|
||||
path = join(config.scriptPath, path);
|
||||
}
|
||||
if (
|
||||
config.writePathList.every(
|
||||
(rootPath) => !isPathInside(rootPath, path),
|
||||
)
|
||||
) {
|
||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
@@ -183,7 +292,8 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(uploadPath)) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -204,8 +314,56 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||
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);
|
||||
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) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -218,7 +376,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -230,6 +388,11 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().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) => {
|
||||
@@ -247,10 +410,14 @@ export default (app: Router) => {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path || '', filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -377,12 +544,21 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath)) {
|
||||
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 result = await scriptService.stopScript(filePath, pid);
|
||||
setTimeout(() => {
|
||||
rmPath(logPath);
|
||||
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (cleanupPath) {
|
||||
void rmPath(cleanupPath);
|
||||
}
|
||||
}, 3000);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
|
||||
+19
-12
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -58,10 +58,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -148,13 +145,27 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
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) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.log(req.params.id);
|
||||
return res.send({ code: 200, data });
|
||||
const result = await subscriptionService.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({ code: 200, data: result.content, ...result });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -190,11 +201,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
typeof req.body.schedule === 'object' ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
|
||||
+48
-10
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
|
||||
import UserService from '../services/user';
|
||||
import { t } from '../shared/i18n';
|
||||
import { isDefaultAuthInfo } from '../shared/auth';
|
||||
import {
|
||||
getUniqPath,
|
||||
handleLogPath,
|
||||
parseVersion,
|
||||
promiseExec,
|
||||
} from '../config/util';
|
||||
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
|
||||
import { findCronId } from '../shared/cronCommand';
|
||||
import dayjs from 'dayjs';
|
||||
import multer from 'multer';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
const route = Router();
|
||||
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(
|
||||
'/reload',
|
||||
celebrate({
|
||||
@@ -249,6 +257,7 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/command-run',
|
||||
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
command: Joi.string().required(),
|
||||
@@ -258,8 +267,13 @@ export default (app: Router) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
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`;
|
||||
let id = await promiseExec(idStr);
|
||||
const crontab = await fs
|
||||
.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 logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
@@ -273,8 +287,13 @@ export default (app: Router) => {
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
res.end();
|
||||
try {
|
||||
await logStreamManager.closeStream(
|
||||
await handleLogPath(logPath),
|
||||
);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(message);
|
||||
@@ -350,6 +369,11 @@ export default (app: Router) => {
|
||||
query: {
|
||||
startTime: Joi.string().allow('').optional(),
|
||||
endTime: Joi.string().allow('').optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
t: Joi.string().optional(),
|
||||
},
|
||||
}),
|
||||
@@ -361,6 +385,7 @@ export default (app: Router) => {
|
||||
req.query as {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
);
|
||||
} 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(
|
||||
'/log',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
+86
-13
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
|
||||
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) => {
|
||||
app.use('/user', route);
|
||||
@@ -35,8 +53,8 @@ export default (app: Router) => {
|
||||
}),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -70,8 +88,8 @@ export default (app: Router) => {
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -80,8 +98,8 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -156,11 +174,12 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/two-factor/login',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
code: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
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(
|
||||
'/notification',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -219,18 +291,19 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/init',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
const result = await userService.initializeUser(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+40
-98
@@ -1,14 +1,11 @@
|
||||
import 'reflect-metadata';
|
||||
import cluster, { type Worker } from 'cluster';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import config from './config';
|
||||
import Logger from './loaders/logger';
|
||||
import { monitoringMiddleware } from './middlewares/monitoring';
|
||||
import { errStack } from './config/util';
|
||||
import { errStack } from './shared/errors';
|
||||
import { initializeLanguageSync } from './shared/languageSync';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
|
||||
@@ -20,35 +17,18 @@ interface WorkerMetadata {
|
||||
}
|
||||
|
||||
class Application {
|
||||
private app: express.Application;
|
||||
private httpServerService?: HttpServerService;
|
||||
private grpcServerService?: GrpcServerService;
|
||||
private manualPrimaryRuntime?: {
|
||||
stop(): Promise<'drained' | 'timed_out'>;
|
||||
};
|
||||
private legacyShadowCaptureEvidence?: {
|
||||
close(): Promise<unknown>;
|
||||
};
|
||||
private isShuttingDown = false;
|
||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||
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() {
|
||||
try {
|
||||
if (cluster.isPrimary) {
|
||||
await this.initializeDatabase();
|
||||
}
|
||||
await initializeLanguageSync();
|
||||
if (cluster.isPrimary) {
|
||||
this.startMasterProcess();
|
||||
} else {
|
||||
@@ -61,11 +41,18 @@ class Application {
|
||||
}
|
||||
|
||||
private startMasterProcess() {
|
||||
const readyTimeout =
|
||||
process.arch === 'arm' &&
|
||||
Number(
|
||||
(process.config.variables as { arm_version?: string | number }).arm_version,
|
||||
) === 6
|
||||
? 120000
|
||||
: 30000;
|
||||
// Fork gRPC worker first and wait for it to be ready
|
||||
const grpcWorker = this.forkWorker('grpc');
|
||||
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
this.waitForWorkerReady(grpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
@@ -85,8 +72,13 @@ class Application {
|
||||
);
|
||||
// If gRPC worker died, restart it and wait for it to be ready
|
||||
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');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('gRPC worker restarted and ready');
|
||||
// Re-register cron jobs by notifying the HTTP worker
|
||||
@@ -138,7 +130,14 @@ class Application {
|
||||
}
|
||||
|
||||
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, {
|
||||
id: worker.id,
|
||||
@@ -151,17 +150,13 @@ class Application {
|
||||
}
|
||||
|
||||
private async initializeDatabase() {
|
||||
const dbLoader = await import('./loaders/db');
|
||||
await dbLoader.default();
|
||||
const { runStartupProcess } = await import('./shared/startupProcess');
|
||||
await runStartupProcess(require.resolve('./bootstrap/database'));
|
||||
}
|
||||
|
||||
private setupMiddlewares() {
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
this.app.use(cors(config.cors));
|
||||
this.app.use(compression());
|
||||
this.app.use(monitoringMiddleware);
|
||||
private async setupMiddlewares(): Promise<express.Application> {
|
||||
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||
return createHttpApplication();
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
@@ -240,41 +235,18 @@ class Application {
|
||||
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||
await initGrpcCerts();
|
||||
|
||||
this.setupMiddlewares();
|
||||
const app = await this.setupMiddlewares();
|
||||
|
||||
const { HttpServerService } = await import('./services/http');
|
||||
this.httpServerService = Container.get(HttpServerService);
|
||||
|
||||
const appLoader = await import('./loaders/app');
|
||||
await appLoader.default({ app: this.app });
|
||||
await appLoader.default({ app });
|
||||
|
||||
const { bootstrapLegacyShadowStartupReconciliation } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
|
||||
const server = await this.httpServerService.initialize(
|
||||
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');
|
||||
await (serverLoader.default as any)({ server });
|
||||
@@ -293,17 +265,10 @@ class Application {
|
||||
process.on('message', async (msg) => {
|
||||
if (msg === 'shutdown') {
|
||||
this.gracefulShutdown(serviceType);
|
||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||
// Re-register cron jobs when gRPC worker restarts
|
||||
try {
|
||||
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||
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)}`);
|
||||
}
|
||||
} else if (serviceType === 'http' &&
|
||||
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
|
||||
const { default: cronClient } = await import('./schedule/client');
|
||||
cronClient.readiness.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -318,9 +283,7 @@ class Application {
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.httpServerService?.shutdown();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
} else {
|
||||
await this.grpcServerService?.shutdown();
|
||||
}
|
||||
@@ -330,27 +293,6 @@ class Application {
|
||||
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();
|
||||
|
||||
@@ -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_VENV_DIR = process.env.PYTHON_VENV_DIR;
|
||||
|
||||
export const NotificationModeStringMap = {
|
||||
0: 'gotify',
|
||||
@@ -51,6 +52,7 @@ export const NotificationModeStringMap = {
|
||||
19: 'ntfy',
|
||||
20: 'wxPusherBot',
|
||||
21: 'wxPusherSpt',
|
||||
22: 'wpush',
|
||||
} as const;
|
||||
|
||||
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 os from 'os';
|
||||
import path from 'path';
|
||||
import config from './index';
|
||||
import { fileExist } from './util';
|
||||
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
|
||||
|
||||
let cachedConfig: GrpcTlsConfig | null = null;
|
||||
|
||||
function run(cmd: string, execOpts?: Record<string, unknown>): string {
|
||||
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
|
||||
return (execSync(cmd, opts) as string).trim();
|
||||
}
|
||||
|
||||
async function tmpFile(prefix: string): Promise<string> {
|
||||
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`);
|
||||
function run(args: string[]): string {
|
||||
return execFileSync('openssl', args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||
|
||||
const caKeyTmp = await tmpFile('ca_key');
|
||||
const caCertTmp = await tmpFile('ca_cert');
|
||||
const serverKeyTmp = await tmpFile('server_key');
|
||||
const serverCsrTmp = await tmpFile('server_csr');
|
||||
const serverExtTmp = await tmpFile('server_ext');
|
||||
const clientKeyTmp = await tmpFile('client_key');
|
||||
const clientCsrTmp = await tmpFile('client_csr');
|
||||
const clientExtTmp = await tmpFile('client_ext');
|
||||
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
|
||||
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
|
||||
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
|
||||
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
|
||||
const caKeyTmp = path.join(workspace, 'ca.key');
|
||||
const caCertTmp = path.join(workspace, 'ca.crt');
|
||||
const serverKeyTmp = path.join(workspace, 'server.key');
|
||||
const serverCsrTmp = path.join(workspace, 'server.csr');
|
||||
const serverExtTmp = path.join(workspace, 'server.ext');
|
||||
const clientKeyTmp = path.join(workspace, 'client.key');
|
||||
const clientCsrTmp = path.join(workspace, 'client.csr');
|
||||
const clientExtTmp = path.join(workspace, 'client.ext');
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
||||
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
||||
try { await fs.unlink(f); } catch {}
|
||||
}
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
|
||||
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
|
||||
run(['genrsa', '-out', caKeyTmp, '2048']);
|
||||
run([
|
||||
'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 caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||
await fs.mkdir(certDir, { recursive: true });
|
||||
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||
|
||||
// 2. 服务端
|
||||
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
|
||||
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
|
||||
const serverCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
|
||||
run(['genrsa', '-out', serverKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
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');
|
||||
|
||||
// 3. 客户端
|
||||
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
|
||||
run(['genrsa', '-out', clientKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
clientKeyTmp,
|
||||
'-out',
|
||||
clientCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-client',
|
||||
]);
|
||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||
const clientCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
||||
);
|
||||
const clientCert = run([
|
||||
'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');
|
||||
|
||||
await cleanup();
|
||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
} catch (e) {
|
||||
} finally {
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
export function createRandomString(min: number, max: number): string {
|
||||
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const english = [
|
||||
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
|
||||
arr.push(getOne(ENGLISH));
|
||||
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++) {
|
||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
||||
arr.push(config[randomInt(config.length)]);
|
||||
}
|
||||
|
||||
const newArr = [];
|
||||
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[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
return arr[randomInt(arr.length)];
|
||||
}
|
||||
|
||||
return newArr.join('');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-26
@@ -1,5 +1,7 @@
|
||||
import { Subscription } from '../data/subscription';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export function formatUrl(doc: Subscription) {
|
||||
let url = doc.url;
|
||||
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
|
||||
if (doc.type === 'private-repo') {
|
||||
if (doc.pull_type === 'ssh-key') {
|
||||
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
||||
url = doc.url!.replace(host, doc.alias);
|
||||
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
|
||||
} else {
|
||||
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
||||
const { username, password } = doc.pull_option as any;
|
||||
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
|
||||
}
|
||||
|
||||
export function formatCommand(doc: Subscription, url?: string) {
|
||||
let command = `SUB_ID=${doc.id} ql `;
|
||||
let _url = url || formatUrl(doc).url;
|
||||
const {
|
||||
type,
|
||||
whitelist,
|
||||
blacklist,
|
||||
dependences,
|
||||
branch,
|
||||
extensions,
|
||||
proxy,
|
||||
autoAddCron,
|
||||
autoDelCron,
|
||||
} = doc;
|
||||
if (type === 'file') {
|
||||
command += `raw "${_url}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
} else {
|
||||
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
|
||||
dependences || ''
|
||||
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
}
|
||||
return command;
|
||||
const args =
|
||||
doc.type === 'file'
|
||||
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
|
||||
: [
|
||||
'repo',
|
||||
url || formatUrl(doc).url,
|
||||
doc.whitelist || '',
|
||||
doc.blacklist || '',
|
||||
doc.dependences || '',
|
||||
doc.branch || '',
|
||||
doc.extensions || '',
|
||||
doc.proxy || '',
|
||||
];
|
||||
args.push(
|
||||
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
|
||||
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
|
||||
);
|
||||
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
|
||||
.map((arg) => shellQuote(arg!))
|
||||
.join(' ')}`;
|
||||
}
|
||||
|
||||
+383
-180
@@ -1,17 +1,25 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { statSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import psTreeFun from 'ps-tree';
|
||||
import { promisify } from 'util';
|
||||
import { load } from 'js-yaml';
|
||||
import config from './index';
|
||||
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
|
||||
import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
|
||||
import Logger from '../loaders/logger';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { DependenceTypes } from '../data/dependence';
|
||||
import { FormData } from 'undici';
|
||||
import os from 'os';
|
||||
import { maybeSudo, isInContainer } from './container';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import {
|
||||
mirrorWriteCommand,
|
||||
normalizeMirror,
|
||||
rewriteMirrorSources,
|
||||
} from '../shared/linuxMirror';
|
||||
|
||||
export * from './share';
|
||||
|
||||
@@ -144,7 +152,8 @@ export async function handleLogPath(
|
||||
logPath: string,
|
||||
data: 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);
|
||||
if (!logFileExist) {
|
||||
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(
|
||||
dir: string,
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
sort: (a: IFile, b: IFile) => number = dirSort,
|
||||
): Promise<IFile[]> {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const files = await fs.readdir(dir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const subPath = path.join(dir, file);
|
||||
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);
|
||||
return readDirsWithRunner(
|
||||
dir,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readDir(
|
||||
@@ -263,26 +329,13 @@ export async function readDir(
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
): Promise<IFile[]> {
|
||||
const absoluteBaseDir = path.resolve(baseDir);
|
||||
const absoluteDir = path.resolve(baseDir, dir);
|
||||
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
|
||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||
return [];
|
||||
}
|
||||
const relativePath = path.relative(baseDir, absoluteDir);
|
||||
|
||||
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 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> {
|
||||
try {
|
||||
const { stderr, stdout } = await promisify(exec)(command, {
|
||||
@@ -456,18 +496,69 @@ export function psTree(pid: number): Promise<number[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function killTask(pid: number) {
|
||||
const pids = await psTree(pid);
|
||||
|
||||
if (pids.length) {
|
||||
export async function killTask(pid: number, waitForExit = false) {
|
||||
const descendants = await psTree(pid);
|
||||
const signal = (target: number, sig: NodeJS.Signals) => {
|
||||
try {
|
||||
[pid, ...pids].reverse().forEach((x) => {
|
||||
process.kill(x, 15);
|
||||
});
|
||||
} catch (error) { }
|
||||
} else {
|
||||
process.kill(pid, 2);
|
||||
process.kill(target, sig);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
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) {
|
||||
@@ -567,11 +658,7 @@ export function safeJSONParse(value?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function errStack(error: unknown): string {
|
||||
return error instanceof Error && error.stack
|
||||
? error.stack
|
||||
: String(error);
|
||||
}
|
||||
export { errStack } from '../shared/errors';
|
||||
|
||||
export async function rmPath(path: string) {
|
||||
try {
|
||||
@@ -590,7 +677,9 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
||||
throw new Error('Invalid timezone');
|
||||
}
|
||||
|
||||
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
|
||||
await promiseExec(
|
||||
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
|
||||
);
|
||||
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
|
||||
|
||||
return true;
|
||||
@@ -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 {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// For Python dependencies installed from GitHub or requirements files,
|
||||
// we can't reliably check if they're installed, so skip the check
|
||||
if (type === DependenceTypes.python3) {
|
||||
if (isPythonSourceDependency(trimmedName)) {
|
||||
// Return a command that will always indicate not installed
|
||||
// This ensures GitHub URLs and requirements files are always installed
|
||||
return 'echo ""';
|
||||
}
|
||||
}
|
||||
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
||||
[DependenceTypes.python3]: `
|
||||
python3 -c "exec('''
|
||||
name='${name}'
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||
`from importlib.metadata import version, PackageNotFoundError
|
||||
import importlib.util
|
||||
import sys
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
print(version(name))
|
||||
except:
|
||||
import importlib.util as u
|
||||
import importlib.metadata as m
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${name}`
|
||||
: maybeSudo(`dpkg-query -s ${name}`),
|
||||
try:
|
||||
from packaging.requirements import Requirement
|
||||
except ImportError:
|
||||
from pip._vendor.packaging.requirements import Requirement
|
||||
requirement = Requirement(sys.argv[1])
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
print('not applicable')
|
||||
else:
|
||||
try:
|
||||
installed = version(requirement.name)
|
||||
except PackageNotFoundError:
|
||||
if not requirement.specifier and not requirement.extras and not requirement.url:
|
||||
if importlib.util.find_spec(requirement.name):
|
||||
print(requirement.name)
|
||||
else:
|
||||
if requirement.specifier.contains(installed, prereleases=True):
|
||||
print(installed)
|
||||
except Exception:
|
||||
pass`,
|
||||
)} ${shellQuote(trimmedName)}`,
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${trimmedName}`
|
||||
: maybeSudo(`dpkg-query -s ${trimmedName}`),
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
}
|
||||
|
||||
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
const prefix =
|
||||
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
|
||||
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
|
||||
: '';
|
||||
const pip = `${getPythonPip()} install --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
}${prefix}`;
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name) || isPyprojectToml(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find((file) => {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return isRequirementsFile(name)
|
||||
? `${pip} -r ${shellQuote(file)}`
|
||||
: `${pip} ${shellQuote(path.dirname(file))}`;
|
||||
}
|
||||
return `${pip} -- ${shellQuote(name)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
};
|
||||
|
||||
let command = baseCommands[type];
|
||||
|
||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||
}
|
||||
|
||||
return `${command} ${name.trim()}`;
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
export function getUninstallCommand(
|
||||
type: DependenceTypes,
|
||||
name: string,
|
||||
): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find((file) => {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
} -y -r ${shellQuote(file)}`;
|
||||
}
|
||||
if (isPyprojectToml(name) || isGitHubUrl(name)) {
|
||||
// A source URL/project can install several distributions and has no safe
|
||||
// inverse. Removing its panel entry leaves installed packages intact.
|
||||
return `printf '%s\\n' ${shellQuote(
|
||||
'Remove installed Python distributions separately by package name.',
|
||||
)}`;
|
||||
}
|
||||
const packageName =
|
||||
name.match(
|
||||
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
|
||||
)?.[1] || name;
|
||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
} -y -- ${shellQuote(packageName)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk del'
|
||||
: maybeSudo('apt-get remove -y'),
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
@@ -726,75 +931,73 @@ export async function detectOS(): Promise<
|
||||
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(
|
||||
osType: string,
|
||||
mirrorDomainWithScheme: string,
|
||||
mirror: string,
|
||||
): Promise<string> {
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
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`;
|
||||
} else {
|
||||
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 defaults: Record<string, string> = {
|
||||
Debian: 'http://deb.debian.org/debian',
|
||||
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
|
||||
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
|
||||
};
|
||||
if (!defaults[osType])
|
||||
throw new Error('Unsupported OS type for updating mirrors.');
|
||||
const target = mirror || defaults[osType];
|
||||
normalizeMirror(target);
|
||||
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> {
|
||||
|
||||
@@ -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 { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export class Crontab {
|
||||
name?: string;
|
||||
@@ -13,6 +12,7 @@ export class Crontab {
|
||||
pid?: number;
|
||||
isDisabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
queued_token?: string | null;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
last_running_time?: number;
|
||||
@@ -61,36 +61,5 @@ export enum CrontabStatus {
|
||||
'queued' = 3,
|
||||
}
|
||||
|
||||
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
|
||||
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
name: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
command: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
schedule: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
timestamp: DataTypes.STRING,
|
||||
saved: DataTypes.BOOLEAN,
|
||||
status: DataTypes.NUMBER,
|
||||
isSystem: DataTypes.NUMBER,
|
||||
pid: DataTypes.NUMBER,
|
||||
isDisabled: DataTypes.NUMBER,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
log_path: DataTypes.STRING,
|
||||
labels: DataTypes.JSON,
|
||||
last_running_time: DataTypes.NUMBER,
|
||||
last_execution_time: DataTypes.NUMBER,
|
||||
sub_id: { type: DataTypes.NUMBER, allowNull: true },
|
||||
extra_schedules: DataTypes.JSON,
|
||||
task_before: DataTypes.STRING,
|
||||
task_after: DataTypes.STRING,
|
||||
log_name: DataTypes.STRING,
|
||||
allow_multiple_instances: DataTypes.NUMBER,
|
||||
work_dir: DataTypes.STRING,
|
||||
});
|
||||
export type CronInstance = RecordRow<Crontab>;
|
||||
export const CrontabModel = new Repository<Crontab>('Crontab');
|
||||
|
||||
+3
-48
@@ -1,5 +1,4 @@
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { sequelize } from '.';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export class CrontabStat {
|
||||
id?: number;
|
||||
@@ -23,49 +22,5 @@ export class CrontabStat {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
|
||||
|
||||
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
|
||||
'CrontabStat',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
ref_id: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
date: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
run_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
success_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
fail_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
total_time: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
max_time: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ unique: true, fields: ['ref_id', 'date'] },
|
||||
{ fields: ['date'] },
|
||||
],
|
||||
},
|
||||
);
|
||||
export type CrontabStatInstance = RecordRow<CrontabStat>;
|
||||
export const CrontabStatModel = new Repository<CrontabStat>('CrontabStat');
|
||||
|
||||
+3
-23
@@ -1,5 +1,4 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export enum CronViewType {
|
||||
'系统' = 1,
|
||||
@@ -39,24 +38,5 @@ export class CrontabView {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CronViewInstance
|
||||
extends Model<CrontabView, CrontabView>,
|
||||
CrontabView {}
|
||||
export const CrontabViewModel = sequelize.define<CronViewInstance>(
|
||||
'CrontabView',
|
||||
{
|
||||
name: {
|
||||
unique: 'name',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
position: DataTypes.NUMBER,
|
||||
isDisabled: DataTypes.NUMBER,
|
||||
filters: DataTypes.JSON,
|
||||
sorts: DataTypes.JSON,
|
||||
filterRelation: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
type: DataTypes.NUMBER,
|
||||
},
|
||||
);
|
||||
export type CronViewInstance = RecordRow<CrontabView>;
|
||||
export const CrontabViewModel = new Repository<CrontabView>('CrontabView');
|
||||
|
||||
@@ -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 { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export class Dependence {
|
||||
timestamp?: string;
|
||||
@@ -47,17 +46,5 @@ export enum versionDependenceCommandTypes {
|
||||
'=',
|
||||
}
|
||||
|
||||
export interface DependenceInstance
|
||||
extends Model<Dependence, Dependence>,
|
||||
Dependence {}
|
||||
export const DependenceModel = sequelize.define<DependenceInstance>(
|
||||
'Dependence',
|
||||
{
|
||||
name: DataTypes.STRING,
|
||||
type: DataTypes.NUMBER,
|
||||
timestamp: DataTypes.STRING,
|
||||
status: DataTypes.NUMBER,
|
||||
log: DataTypes.JSON,
|
||||
remark: DataTypes.STRING,
|
||||
},
|
||||
);
|
||||
export type DependenceInstance = RecordRow<Dependence>;
|
||||
export const DependenceModel = new Repository<Dependence>('Dependence');
|
||||
|
||||
+3
-13
@@ -1,5 +1,4 @@
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { sequelize } from '.';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export class Env {
|
||||
value?: string;
|
||||
@@ -38,14 +37,5 @@ export const initPosition = 4500000000000000;
|
||||
export const stepPosition = 10000000000;
|
||||
export const minPosition = 100;
|
||||
|
||||
export interface EnvInstance extends Model<Env, Env>, Env {}
|
||||
export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
||||
value: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
timestamp: DataTypes.STRING,
|
||||
status: DataTypes.NUMBER,
|
||||
position: DataTypes.NUMBER,
|
||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
remarks: DataTypes.STRING,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
export type EnvInstance = RecordRow<Env>;
|
||||
export const EnvModel = new Repository<Env>('Env');
|
||||
|
||||
+5
-18
@@ -1,23 +1,10 @@
|
||||
import { Sequelize, Transaction } from 'sequelize';
|
||||
import config from '../config/index';
|
||||
import { join } from 'path';
|
||||
import { DatabaseClient } from './databaseClient';
|
||||
|
||||
export const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: join(config.dbPath, 'database.sqlite'),
|
||||
logging: false,
|
||||
retry: {
|
||||
max: 10,
|
||||
match: ['SQLITE_BUSY: database is locked'],
|
||||
},
|
||||
pool: {
|
||||
max: 5,
|
||||
min: 2,
|
||||
idle: 30000,
|
||||
acquire: 30000,
|
||||
evict: 10000,
|
||||
},
|
||||
transactionType: Transaction.TYPES.IMMEDIATE,
|
||||
});
|
||||
export const database = new DatabaseClient(
|
||||
join(config.dbPath, 'database.sqlite'),
|
||||
join(config.dbPath, 'keyv.sqlite'),
|
||||
);
|
||||
|
||||
export type ResponseType<T> = { code: number; data?: T; message?: string };
|
||||
|
||||
+9
-1
@@ -21,6 +21,7 @@ export enum NotificationMode {
|
||||
'ntfy' = 'ntfy',
|
||||
'wxPusherBot' = 'wxPusherBot',
|
||||
'openiLink' = 'openiLink',
|
||||
'wpush' = 'wpush',
|
||||
}
|
||||
|
||||
abstract class NotificationBaseInfo {
|
||||
@@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo {
|
||||
public openiLinkContextToken = '';
|
||||
}
|
||||
|
||||
export class WpushNotification extends NotificationBaseInfo {
|
||||
public wpushApiKey = '';
|
||||
public wpushChannel = '';
|
||||
public wpushTopicCode = '';
|
||||
}
|
||||
|
||||
export interface NotificationInfo
|
||||
extends GoCqHttpBotNotification,
|
||||
GotifyNotification,
|
||||
@@ -195,4 +202,5 @@ export interface NotificationInfo
|
||||
NtfyNotification,
|
||||
WxPusherBotNotification,
|
||||
WxPusherSptNotification,
|
||||
OpeniLinkNotification {}
|
||||
OpeniLinkNotification,
|
||||
WpushNotification {}
|
||||
|
||||
+11
-11
@@ -1,5 +1,4 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export class App {
|
||||
name: string;
|
||||
@@ -24,13 +23,14 @@ export interface AppToken {
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
|
||||
export type AppScope =
|
||||
| 'envs'
|
||||
| 'crons'
|
||||
| 'configs'
|
||||
| 'scripts'
|
||||
| 'logs'
|
||||
| 'system'
|
||||
| 'dashboard';
|
||||
|
||||
export interface AppInstance extends Model<App, App>, App {}
|
||||
export const AppModel = sequelize.define<AppInstance>('App', {
|
||||
name: { type: DataTypes.STRING, unique: 'name' },
|
||||
scopes: DataTypes.JSON,
|
||||
client_id: DataTypes.STRING,
|
||||
client_secret: DataTypes.STRING,
|
||||
tokens: DataTypes.JSON,
|
||||
});
|
||||
export type AppInstance = RecordRow<App>;
|
||||
export const AppModel = new Repository<App>('App');
|
||||
|
||||
@@ -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 { DataTypes, Model } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
|
||||
export enum InstanceStatus {
|
||||
'running' = 0,
|
||||
@@ -11,8 +10,6 @@ export enum InstanceStatus {
|
||||
export interface RunningInstanceAttributes {
|
||||
id?: number;
|
||||
cron_id: number;
|
||||
run_id?: string | null;
|
||||
attempt_id?: string | null;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at: number;
|
||||
@@ -24,8 +21,6 @@ export interface RunningInstanceAttributes {
|
||||
export class RunningInstance {
|
||||
id?: number;
|
||||
cron_id!: number;
|
||||
run_id?: string | null;
|
||||
attempt_id?: string | null;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at!: number;
|
||||
@@ -36,8 +31,6 @@ export class RunningInstance {
|
||||
constructor(options: RunningInstanceAttributes) {
|
||||
this.id = options.id;
|
||||
this.cron_id = options.cron_id;
|
||||
this.run_id = options.run_id;
|
||||
this.attempt_id = options.attempt_id;
|
||||
this.pid = options.pid;
|
||||
this.log_path = options.log_path;
|
||||
this.started_at = options.started_at;
|
||||
@@ -47,50 +40,7 @@ export class RunningInstance {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunningInstanceModel
|
||||
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
|
||||
RunningInstanceAttributes {}
|
||||
|
||||
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
|
||||
export type RunningInstanceModel = RecordRow<RunningInstanceAttributes>;
|
||||
export const RunningInstanceModel = new Repository<RunningInstanceAttributes>(
|
||||
'RunningInstance',
|
||||
{
|
||||
cron_id: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
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 { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||
|
||||
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
|
||||
@@ -20,7 +19,7 @@ export class Subscription {
|
||||
pull_option?:
|
||||
| { private_key: string }
|
||||
| { username: string; password: string };
|
||||
pid?: number;
|
||||
pid?: number | null;
|
||||
is_disabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
alias: string;
|
||||
@@ -70,46 +69,5 @@ export enum SubscriptionStatus {
|
||||
'queued',
|
||||
}
|
||||
|
||||
export interface SubscriptionInstance
|
||||
extends Model<Subscription, Subscription>,
|
||||
Subscription {}
|
||||
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
|
||||
'Subscription',
|
||||
{
|
||||
name: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
url: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
schedule: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
interval_schedule: {
|
||||
unique: 'compositeIndex',
|
||||
type: DataTypes.JSON,
|
||||
},
|
||||
type: DataTypes.STRING,
|
||||
whitelist: DataTypes.STRING,
|
||||
blacklist: DataTypes.STRING,
|
||||
status: DataTypes.NUMBER,
|
||||
dependences: DataTypes.STRING,
|
||||
extensions: DataTypes.STRING,
|
||||
sub_before: DataTypes.STRING,
|
||||
sub_after: DataTypes.STRING,
|
||||
branch: DataTypes.STRING,
|
||||
pull_type: DataTypes.STRING,
|
||||
pull_option: DataTypes.JSON,
|
||||
pid: DataTypes.NUMBER,
|
||||
is_disabled: DataTypes.NUMBER,
|
||||
log_path: DataTypes.STRING,
|
||||
schedule_type: DataTypes.STRING,
|
||||
alias: { type: DataTypes.STRING, unique: 'alias' },
|
||||
proxy: { type: DataTypes.STRING, allowNull: true },
|
||||
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
},
|
||||
);
|
||||
export type SubscriptionInstance = RecordRow<Subscription>;
|
||||
export const SubscriptionModel = new Repository<Subscription>('Subscription');
|
||||
|
||||
+24
-13
@@ -1,5 +1,4 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
import { Repository, RecordRow } from './repository';
|
||||
import { NotificationInfo } from './notify';
|
||||
|
||||
export class SystemInfo {
|
||||
@@ -28,8 +27,12 @@ export enum AuthDataType {
|
||||
'removeLogFrequency' = 'removeLogFrequency',
|
||||
'systemConfig' = 'systemConfig',
|
||||
'authConfig' = 'authConfig',
|
||||
'notifyLog' = 'notifyLog',
|
||||
}
|
||||
|
||||
export const NotifyStatus = LoginStatus;
|
||||
export type NotifyStatus = LoginStatus;
|
||||
|
||||
export interface SystemConfigInfo {
|
||||
lang?: string;
|
||||
panelTitle?: string;
|
||||
@@ -41,6 +44,8 @@ export interface SystemConfigInfo {
|
||||
linuxMirror?: string;
|
||||
timezone?: string;
|
||||
globalSshKey?: string;
|
||||
runningInstanceRetentionDays?: number;
|
||||
cronStatRetentionDays?: number;
|
||||
}
|
||||
|
||||
export interface LoginLogInfo {
|
||||
@@ -51,6 +56,17 @@ export interface LoginLogInfo {
|
||||
status?: LoginStatus;
|
||||
}
|
||||
|
||||
export interface NotifyLogInfo {
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: NotifyStatus;
|
||||
notifyType?: string;
|
||||
source?: 'system' | 'script';
|
||||
scriptName?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
value: string;
|
||||
timestamp: number;
|
||||
@@ -73,26 +89,21 @@ export interface AuthInfo {
|
||||
lastaddr: string;
|
||||
platform: string;
|
||||
isTwoFactorChecking: boolean;
|
||||
twoFactorExpiresAt?: number;
|
||||
lastTwoFactorStep?: number;
|
||||
token: string;
|
||||
tokens: Record<string, string | TokenInfo[]>;
|
||||
twoFactorActivated: boolean;
|
||||
twoFactorSecret: string;
|
||||
avatar: string;
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
Partial<NotificationInfo> &
|
||||
LoginLogInfo &
|
||||
Partial<NotifyLogInfo> &
|
||||
Partial<AuthInfo>;
|
||||
|
||||
export interface SystemInstance
|
||||
extends Model<SystemInfo, SystemInfo>,
|
||||
SystemInfo {}
|
||||
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
|
||||
ip: DataTypes.STRING,
|
||||
type: DataTypes.STRING,
|
||||
info: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
},
|
||||
});
|
||||
export type SystemInstance = RecordRow<SystemInfo>;
|
||||
export const SystemModel = new Repository<SystemInfo>('Auth');
|
||||
|
||||
@@ -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 initTask from './initTask';
|
||||
import initFile from './initFile';
|
||||
import { initializeTrustProxy } from '../shared/trustProxy';
|
||||
|
||||
export default async ({ app }: { app: Application }) => {
|
||||
depInjectorLoader();
|
||||
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app });
|
||||
await initializeTrustProxy(app);
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+7
-21
@@ -1,28 +1,14 @@
|
||||
import Logger from './logger';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { DependenceModel } from '../data/dependence';
|
||||
import { AppModel } from '../data/open';
|
||||
import { SystemModel } from '../data/system';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { runMigrations } from '../migrations/runner';
|
||||
import { database } from '../data';
|
||||
import { initialSchema } from '../data/schema';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
await CrontabModel.sync();
|
||||
await DependenceModel.sync();
|
||||
await AppModel.sync();
|
||||
await SystemModel.sync();
|
||||
await EnvModel.sync();
|
||||
await SubscriptionModel.sync();
|
||||
await CrontabViewModel.sync();
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
await runMigrations();
|
||||
|
||||
await database.transaction(async () => {
|
||||
for (const sql of initialSchema) await database.query(sql);
|
||||
await migrateSchema(database);
|
||||
});
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
|
||||
+45
-1
@@ -17,6 +17,50 @@ async function linkCommand() {
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
src: 'update.sh',
|
||||
@@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) {
|
||||
if (stats) {
|
||||
await fs.unlink(tmpTarget);
|
||||
}
|
||||
} catch (error) { }
|
||||
} catch (error) {}
|
||||
|
||||
await fs.symlink(source, tmpTarget);
|
||||
await fs.rename(tmpTarget, target);
|
||||
|
||||
+134
-118
@@ -6,7 +6,7 @@ import config from '../config';
|
||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||
import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import { errors } from 'celebrate';
|
||||
import validationErrors from '../middlewares/validationErrors';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
@@ -14,46 +14,60 @@ import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { AppScope } from '../data/open';
|
||||
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
import { withAuthAvailability } from '../middlewares/authAvailability';
|
||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||
const setting = value?.trim();
|
||||
if (!setting) {
|
||||
return 'loopback';
|
||||
}
|
||||
if (setting === 'true' || setting === 'false') {
|
||||
return setting === 'true';
|
||||
}
|
||||
if (/^\d+$/.test(setting)) {
|
||||
return Number(setting);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
export default ({ app }: { app: Application }) => {
|
||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||
app.set('case sensitive routing', true);
|
||||
app.set('strict routing', true);
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.set('trust proxy', resolveTrustProxy());
|
||||
app.use(cors());
|
||||
|
||||
// Security: Path normalization middleware to prevent case variation attacks
|
||||
app.use((req, res, next) => {
|
||||
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();
|
||||
});
|
||||
|
||||
// Security: Reject case variations under protected API namespaces before
|
||||
// authentication checks can interpret the request differently from routing.
|
||||
app.use(protectedPathCase);
|
||||
// Rewrite URLs to strip baseUrl prefix if configured
|
||||
// This allows the rest of the app to work without baseUrl awareness
|
||||
if (config.baseUrl) {
|
||||
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
||||
}
|
||||
|
||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||
app.use(`${config.api.prefix}/static`, 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.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
const frontendPath = path.join(config.rootPath, 'static/dist');
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
app.use(
|
||||
expressjwt({
|
||||
secret: config.jwt.secret,
|
||||
@@ -62,7 +76,6 @@ export default ({ app }: { app: Application }) => {
|
||||
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use((req: Request, res, next) => {
|
||||
if (!req.headers) {
|
||||
req.platform = 'desktop';
|
||||
@@ -72,92 +85,92 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use(async (req: Request, res, next) => {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const headerToken = getToken(req);
|
||||
if (pathLower.startsWith('/open/')) {
|
||||
const apps = await shareStore.getApps();
|
||||
const doc = apps?.filter((x) =>
|
||||
x.tokens?.find((y) => y.value === headerToken),
|
||||
)?.[0];
|
||||
if (doc && doc.tokens && doc.tokens.length > 0) {
|
||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||
const key = keyMatch && keyMatch[1];
|
||||
|
||||
if (!doc.scopes.includes(key as AppScope)) {
|
||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
|
||||
const err = new UnauthorizedError('invalid_token', {
|
||||
message: t('Token 已失效'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
|
||||
app.use(
|
||||
withAuthAvailability(async (req: Request, res, next) => {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
||||
if (
|
||||
!headerToken &&
|
||||
originPath &&
|
||||
config.apiWhiteList.includes(originPath)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken
|
||||
? t('Token 已失效')
|
||||
: t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
});
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (
|
||||
![
|
||||
'/api/user/init',
|
||||
'/api/user/notification/init',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
].includes(pathLower)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
|
||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
|
||||
if (isInitialized) {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
});
|
||||
|
||||
const headerToken = getToken(req);
|
||||
if (pathLower.startsWith('/open/')) {
|
||||
const apps = await shareStore.getApps();
|
||||
const doc = apps?.filter((x) =>
|
||||
x.tokens?.find((y) => y.value === headerToken),
|
||||
)?.[0];
|
||||
if (doc && doc.tokens && doc.tokens.length > 0) {
|
||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||
const key = keyMatch && keyMatch[1];
|
||||
if (!doc.scopes.includes(key as AppScope)) {
|
||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
if (
|
||||
!currentToken ||
|
||||
currentToken.expiration < Math.round(Date.now() / 1000)
|
||||
) {
|
||||
const err = new UnauthorizedError('invalid_token', {
|
||||
message: t('Token 已失效'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
}
|
||||
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
||||
if (
|
||||
!headerToken &&
|
||||
originPath &&
|
||||
config.apiWhiteList.includes(originPath)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (
|
||||
isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
withAuthAvailability(async (req, res, next) => {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (
|
||||
![
|
||||
'/api/user/init',
|
||||
'/api/user/notification/init',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
].includes(pathLower)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
if (isInitialized) {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}),
|
||||
);
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
|
||||
app.get('*', (_, res, next) => {
|
||||
app.get('*', (req, res, next) => {
|
||||
// Don't serve index.html for API routes
|
||||
if (
|
||||
req.path === config.api.prefix ||
|
||||
req.path.startsWith(`${config.api.prefix}/`)
|
||||
) {
|
||||
return res.status(404).json({ code: 404, message: 'Not Found' });
|
||||
}
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
@@ -167,12 +180,13 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
|
||||
app.use(validationErrors);
|
||||
app.use(uniqueConstraintError);
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
err: Error & {
|
||||
status: number;
|
||||
},
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -186,15 +200,16 @@ export default ({ app }: { app: Application }) => {
|
||||
return next(err);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { errors: any[] },
|
||||
err: Error & {
|
||||
errors: any[];
|
||||
},
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (err.name.includes('Sequelize')) {
|
||||
if (err.name === 'DatabaseError') {
|
||||
return res
|
||||
.status(500)
|
||||
.send({
|
||||
@@ -207,10 +222,11 @@ export default ({ app }: { app: Application }) => {
|
||||
return next(err);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
err: Error & {
|
||||
status: number;
|
||||
},
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
|
||||
@@ -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 { exec } from 'child_process';
|
||||
import { Container } from 'typedi';
|
||||
@@ -5,7 +7,6 @@ import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import CronService from '../services/cron';
|
||||
import EnvService from '../services/env';
|
||||
import { DependenceModel, DependenceStatus } from '../data/dependence';
|
||||
import { Op } from 'sequelize';
|
||||
import config from '../config';
|
||||
import { CrontabViewModel, CronViewType } from '../data/cronView';
|
||||
import { initPosition } from '../data/env';
|
||||
@@ -13,14 +14,13 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import SystemService from '../services/system';
|
||||
import UserService from '../services/user';
|
||||
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 { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import { AppModel } from '../data/open';
|
||||
import cronClient from '../schedule/client';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
|
||||
export default async () => {
|
||||
const cronService = Container.get(CronService);
|
||||
const envService = Container.get(EnvService);
|
||||
@@ -28,34 +28,14 @@ export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
const userService = Container.get(UserService);
|
||||
const openService = Container.get(OpenService);
|
||||
|
||||
// 初始化增加系统配置
|
||||
let systemApp = (
|
||||
await AppModel.findOne({
|
||||
where: { name: 'system' },
|
||||
})
|
||||
)?.get({ plain: true });
|
||||
if (!systemApp) {
|
||||
systemApp = await AppModel.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system', 'dashboard'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
} else if (!systemApp.scopes.includes('dashboard')) {
|
||||
await AppModel.update(
|
||||
{ scopes: [...systemApp.scopes, 'dashboard'] },
|
||||
{ where: { name: 'system' } },
|
||||
);
|
||||
}
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
await openService.initializeSystemApp();
|
||||
const systemConfig = await SystemModel.getOrCreate({
|
||||
type: AuthDataType.systemConfig,
|
||||
});
|
||||
await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.notification },
|
||||
});
|
||||
const [authConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.authConfig },
|
||||
await SystemModel.getOrCreate({ type: AuthDataType.notification });
|
||||
const authConfig = await SystemModel.getOrCreate({
|
||||
type: AuthDataType.authConfig,
|
||||
});
|
||||
if (!authConfig?.info || isDemoEnv()) {
|
||||
let authInfo = {
|
||||
@@ -71,36 +51,34 @@ export default async () => {
|
||||
} catch (error) {
|
||||
Logger.warn('Failed to read auth config file, using default credentials');
|
||||
}
|
||||
await SystemModel.upsert({
|
||||
id: authConfig?.id,
|
||||
info: authInfo,
|
||||
type: AuthDataType.authConfig,
|
||||
});
|
||||
await SystemModel.nativeUpdate(
|
||||
{ id: authConfig.id },
|
||||
{
|
||||
info: authInfo,
|
||||
type: AuthDataType.authConfig,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const installDependencies = async () => {
|
||||
const docs = await DependenceModel.findAll({
|
||||
where: {},
|
||||
order: [
|
||||
['type', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
await DependenceModel.update(
|
||||
const docs = await DependenceModel.find(
|
||||
{},
|
||||
{
|
||||
orderBy: orderBy([
|
||||
['type', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
]),
|
||||
},
|
||||
);
|
||||
await DependenceModel.nativeUpdate(
|
||||
{ id: docs.map((x) => x.id!) },
|
||||
{ status: DependenceStatus.queued, log: [] },
|
||||
{ where: { id: docs.map((x) => x.id!) } },
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
await dependenceService.installDependenceOneByOne(docs);
|
||||
|
||||
const bootAfterLoader = await import('./bootAfter');
|
||||
bootAfterLoader.default();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// 初始化更新 linux/python/nodejs 镜像源配置
|
||||
if (systemConfig.info?.pythonMirror) {
|
||||
systemService.updatePythonMirror({
|
||||
@@ -123,39 +101,40 @@ export default async () => {
|
||||
nodeMirror: systemConfig.info?.nodeMirror,
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化新增默认全部任务视图
|
||||
CrontabViewModel.findAll({
|
||||
where: { type: CronViewType.系统, name: '全部任务' },
|
||||
raw: true,
|
||||
}).then((docs) => {
|
||||
if (docs.length === 0) {
|
||||
CrontabViewModel.create({
|
||||
name: '全部任务',
|
||||
type: CronViewType.系统,
|
||||
position: initPosition / 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化更新所有任务状态为空闲
|
||||
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%` }],
|
||||
},
|
||||
CrontabViewModel.find({ type: CronViewType.系统, name: '全部任务' }, {}).then(
|
||||
(docs) => {
|
||||
if (docs.length === 0) {
|
||||
CrontabViewModel.insert({
|
||||
name: '全部任务',
|
||||
type: CronViewType.系统,
|
||||
position: initPosition / 2,
|
||||
});
|
||||
}
|
||||
},
|
||||
}).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++) {
|
||||
const doc = docs[i];
|
||||
if (doc) {
|
||||
@@ -163,87 +142,79 @@ export default async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 更新2.11.3以前的脚本路径
|
||||
CrontabModel.findAll({
|
||||
where: {
|
||||
command: {
|
||||
[Op.or]: [
|
||||
{ [Op.like]: `%\/${config.rootPath}\/scripts\/%` },
|
||||
{ [Op.like]: `%\/${config.rootPath}\/config\/%` },
|
||||
{ [Op.like]: `%\/${config.rootPath}\/log\/%` },
|
||||
{ [Op.like]: `%\/${config.rootPath}\/db\/%` },
|
||||
],
|
||||
},
|
||||
CrontabModel.find(
|
||||
{
|
||||
$or: ['scripts', 'config', 'log', 'db'].flatMap(
|
||||
(folder) =>
|
||||
textSearch(['command'], `/${config.rootPath}/${folder}/`).$or,
|
||||
),
|
||||
},
|
||||
}).then(async (docs) => {
|
||||
{},
|
||||
).then(async (docs) => {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
if (doc) {
|
||||
if (doc.command.includes(`${config.rootPath}/scripts/`)) {
|
||||
await CrontabModel.update(
|
||||
await CrontabModel.nativeUpdate(
|
||||
{ id: doc.id },
|
||||
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
}
|
||||
if (doc.command.includes(`${config.rootPath}/log/`)) {
|
||||
await CrontabModel.update(
|
||||
await CrontabModel.nativeUpdate(
|
||||
{ id: doc.id },
|
||||
{
|
||||
command: `${config.dataPath}/log/${doc.command.replace(
|
||||
`${config.rootPath}/log/`,
|
||||
'',
|
||||
)}`,
|
||||
},
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
}
|
||||
if (doc.command.includes(`${config.rootPath}/config/`)) {
|
||||
await CrontabModel.update(
|
||||
await CrontabModel.nativeUpdate(
|
||||
{ id: doc.id },
|
||||
{
|
||||
command: `${config.dataPath}/config/${doc.command.replace(
|
||||
`${config.rootPath}/config/`,
|
||||
'',
|
||||
)}`,
|
||||
},
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
}
|
||||
if (doc.command.includes(`${config.rootPath}/db/`)) {
|
||||
await CrontabModel.update(
|
||||
await CrontabModel.nativeUpdate(
|
||||
{ id: doc.id },
|
||||
{
|
||||
command: `${config.dataPath}/db/${doc.command.replace(
|
||||
`${config.rootPath}/db/`,
|
||||
'',
|
||||
)}`,
|
||||
},
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化语言(必须在 autosave_crontab 之前)
|
||||
const lang = systemConfig.info?.lang || systemLang();
|
||||
setLang(lang);
|
||||
|
||||
// 确保 lang_env.sh 存在
|
||||
try {
|
||||
const langEnvExist = await fileExist(config.langEnvFile);
|
||||
if (!langEnvExist) {
|
||||
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
|
||||
}
|
||||
} catch { }
|
||||
|
||||
} catch {}
|
||||
// 初始化保存一次ck和定时任务数据
|
||||
await cronService.autosave_crontab();
|
||||
|
||||
cronClient.readiness.configure(async () => {
|
||||
await cronService.autosave_crontab(true);
|
||||
await systemService.restoreCronConcurrency();
|
||||
});
|
||||
await cronClient.readiness.recover();
|
||||
await envService.set_envs();
|
||||
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
const apps = await openService.findApps();
|
||||
await shareStore.updateAuthInfo(authInfo);
|
||||
if (apps?.length) {
|
||||
await shareStore.updateApps(apps);
|
||||
}
|
||||
await openService.refreshApps();
|
||||
};
|
||||
|
||||
@@ -7,23 +7,23 @@ import config from '../config';
|
||||
import { fileExist } from '../config/util';
|
||||
import { join } from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
import Logger from './logger';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
const scheduleService = Container.get(ScheduleService);
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const sshKeyService = Container.get(SshKeyService);
|
||||
|
||||
// 生成内置token
|
||||
let tokenCommand = `ts-node-transpile-only ${join(
|
||||
config.rootPath,
|
||||
'back/token.ts',
|
||||
)}`;
|
||||
const tokenFile = join(config.rootPath, 'static/build/token.js');
|
||||
|
||||
if (await fileExist(tokenFile)) {
|
||||
tokenCommand = `node ${tokenFile}`;
|
||||
}
|
||||
tokenCommand += ' --renew';
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: t('生成token'),
|
||||
@@ -38,7 +38,6 @@ export default async () => {
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 运行删除日志任务
|
||||
const data = await systemService.getSystemConfig();
|
||||
if (data && data.info) {
|
||||
@@ -58,18 +57,21 @@ export default async () => {
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
systemService.updateTimezone(data.info);
|
||||
|
||||
// Apply global SSH key if configured
|
||||
if (data.info.globalSshKey) {
|
||||
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||
}
|
||||
}
|
||||
|
||||
await subscriptionService.setSshConfig();
|
||||
const subs = await subscriptionService.list();
|
||||
for (const sub of subs) {
|
||||
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 { Container } from 'typedi';
|
||||
import SockService from '../services/sock';
|
||||
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
|
||||
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 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) => {
|
||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||
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 headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform)) {
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
sessionTimer = setInterval(checkSessions, 1000);
|
||||
sessionTimer.unref();
|
||||
}
|
||||
|
||||
conn.on('data', (message) => {
|
||||
conn.write(message);
|
||||
});
|
||||
|
||||
conn.on('close', function () {
|
||||
sockService.removeClient(conn);
|
||||
});
|
||||
|
||||
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 { metricsService } from '../services/metrics';
|
||||
|
||||
interface RequestMetrics {
|
||||
method: string;
|
||||
path: string;
|
||||
duration: number;
|
||||
statusCode: number;
|
||||
timestamp: number;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
const requestMetrics: RequestMetrics[] = [];
|
||||
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
|
||||
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
|
||||
let requestSampleOffset = 0;
|
||||
|
||||
export const monitoringMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const originalEnd = res.end;
|
||||
|
||||
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
||||
const duration = performance.now() - start;
|
||||
const metric: RequestMetrics = {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
duration,
|
||||
statusCode: res.statusCode,
|
||||
timestamp: Date.now(),
|
||||
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();
|
||||
const shouldSample = requestSampleOffset === 0;
|
||||
requestSampleOffset =
|
||||
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
|
||||
if (shouldSample) {
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,447 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
|
||||
APPROVED_ACTION_RECOVERY_DECISIONS,
|
||||
APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
APPROVED_ACTION_RECOVERY_SOURCES,
|
||||
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
} from '../runtime/domain/approvedActionRecovery';
|
||||
import { PROJECT_TABLE } from './0017-project-policy';
|
||||
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from './0021-approved-action-dispatch-executions';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const APPROVED_ACTION_RECOVERY_CONTROL_TABLE =
|
||||
'ApprovedActionRecoveryControls';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE =
|
||||
'ApprovedActionRecoveryResolutions';
|
||||
export const APPROVED_ACTION_RECOVERY_DUE_INDEX =
|
||||
'approved_action_recovery_due_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_PROJECT_INDEX =
|
||||
'approved_action_recovery_project_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_LEASE_INDEX =
|
||||
'approved_action_recovery_lease_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX =
|
||||
'approved_action_recovery_resolution_mutation_uidx';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX =
|
||||
'approved_action_recovery_resolution_project_idx';
|
||||
|
||||
const controlColumns = [
|
||||
'dispatch_id',
|
||||
'project_id',
|
||||
'execution_version',
|
||||
'status',
|
||||
'version',
|
||||
'next_scan_at_ms',
|
||||
'lease_owner',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'finding_count',
|
||||
'last_finding_mutation_id',
|
||||
'last_finding',
|
||||
'last_result_code',
|
||||
'last_evidence_digest',
|
||||
'resolution_mutation_id',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
];
|
||||
|
||||
const resolutionColumns = [
|
||||
'dispatch_id',
|
||||
'project_id',
|
||||
'execution_version',
|
||||
'mutation_id',
|
||||
'source',
|
||||
'decision',
|
||||
'evidence_digest',
|
||||
'reason_code',
|
||||
'resolved_by_type',
|
||||
'resolved_by_id',
|
||||
'resolved_at_ms',
|
||||
];
|
||||
|
||||
const constraints = [
|
||||
'approved_action_recovery_status_check',
|
||||
'approved_action_recovery_execution_version_check',
|
||||
'approved_action_recovery_version_check',
|
||||
'approved_action_recovery_finding_count_check',
|
||||
'approved_action_recovery_lease_tuple_check',
|
||||
'approved_action_recovery_finding_tuple_check',
|
||||
'approved_action_recovery_finding_value_check',
|
||||
'approved_action_recovery_evidence_digest_check',
|
||||
'approved_action_recovery_resolution_mutation_tuple_check',
|
||||
'approved_action_recovery_timestamps_check',
|
||||
'approved_action_recovery_resolution_execution_version_check',
|
||||
'approved_action_recovery_resolution_source_check',
|
||||
'approved_action_recovery_resolution_decision_check',
|
||||
'approved_action_recovery_resolution_actor_tuple_check',
|
||||
'approved_action_recovery_resolution_source_tuple_check',
|
||||
'approved_action_recovery_resolution_evidence_digest_check',
|
||||
'approved_action_recovery_resolution_time_check',
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
tables: {
|
||||
ApprovedActionRecoveryControls: controlColumns,
|
||||
ApprovedActionRecoveryResolutions: resolutionColumns,
|
||||
},
|
||||
indexes: [
|
||||
`${APPROVED_ACTION_RECOVERY_DUE_INDEX}(status,next_scan_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX}(mutation_id) UNIQUE`,
|
||||
`${APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX}(project_id,resolved_at_ms,dispatch_id)`,
|
||||
],
|
||||
constraints,
|
||||
};
|
||||
|
||||
export const approvedActionRecoveryManifest = manifest;
|
||||
|
||||
export const approvedActionRecoveryMigration: Migration = {
|
||||
id: '0022-approved-action-recovery',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
{
|
||||
dispatch_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: {
|
||||
model: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
key: 'dispatch_id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
execution_version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
status: { type: DataTypes.STRING(24), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
next_scan_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 },
|
||||
finding_count: { type: DataTypes.INTEGER, allowNull: false },
|
||||
last_finding_mutation_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
last_finding: { type: DataTypes.STRING(24), allowNull: true },
|
||||
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
|
||||
last_evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
|
||||
resolution_mutation_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
dispatch_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: {
|
||||
model: APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
key: 'dispatch_id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
execution_version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
||||
source: { type: DataTypes.STRING(24), allowNull: false },
|
||||
decision: { type: DataTypes.STRING(24), allowNull: false },
|
||||
evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
|
||||
reason_code: { type: DataTypes.STRING(64), allowNull: false },
|
||||
resolved_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
||||
resolved_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
resolved_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
for (const [table, field, minimum, maximum, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'execution_version',
|
||||
1,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_execution_version_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'version',
|
||||
0,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_version_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'finding_count',
|
||||
0,
|
||||
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
'approved_action_recovery_finding_count_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'execution_version',
|
||||
1,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_resolution_execution_version_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.between]: [minimum, maximum] } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const [table, field, values, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'status',
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
|
||||
'approved_action_recovery_status_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'last_finding',
|
||||
APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
'approved_action_recovery_finding_value_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'source',
|
||||
APPROVED_ACTION_RECOVERY_SOURCES,
|
||||
'approved_action_recovery_resolution_source_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'decision',
|
||||
APPROVED_ACTION_RECOVERY_DECISIONS,
|
||||
'approved_action_recovery_resolution_decision_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_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_recovery_lease_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['last_finding_mutation_id', 'last_finding', 'last_result_code'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
last_finding_mutation_id: null,
|
||||
last_finding: null,
|
||||
last_result_code: null,
|
||||
},
|
||||
{
|
||||
last_finding_mutation_id: { [Op.not]: null },
|
||||
last_finding: { [Op.not]: null },
|
||||
last_result_code: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_finding_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [table, field, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'last_evidence_digest',
|
||||
'approved_action_recovery_evidence_digest_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'evidence_digest',
|
||||
'approved_action_recovery_resolution_evidence_digest_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ [field]: null },
|
||||
queryInterface.sequelize.where(
|
||||
queryInterface.sequelize.fn(
|
||||
'length',
|
||||
queryInterface.sequelize.col(field),
|
||||
),
|
||||
64,
|
||||
),
|
||||
],
|
||||
},
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['status', 'resolution_mutation_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ status: 'resolved', resolution_mutation_id: { [Op.not]: null } },
|
||||
{
|
||||
status: { [Op.not]: 'resolved' },
|
||||
resolution_mutation_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_mutation_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['created_at_ms', 'updated_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
created_at_ms: { [Op.gte]: 0 },
|
||||
updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } },
|
||||
},
|
||||
name: 'approved_action_recovery_timestamps_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['resolved_by_type', 'resolved_by_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ resolved_by_type: null, resolved_by_id: null },
|
||||
{
|
||||
resolved_by_type: 'user',
|
||||
resolved_by_id: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_actor_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['source', 'decision', 'evidence_digest', 'resolved_by_type'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
source: 'automatic_evidence',
|
||||
decision: {
|
||||
[Op.in]: ['confirm_succeeded', 'confirm_failed'],
|
||||
},
|
||||
evidence_digest: { [Op.not]: null },
|
||||
resolved_by_type: null,
|
||||
},
|
||||
{ source: 'human', resolved_by_type: 'user' },
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_source_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['resolved_at_ms'],
|
||||
type: 'check',
|
||||
where: { resolved_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'approved_action_recovery_resolution_time_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
['status', 'next_scan_at_ms', 'dispatch_id'],
|
||||
{ name: APPROVED_ACTION_RECOVERY_DUE_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_RECOVERY_PROJECT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_RECOVERY_LEASE_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
['mutation_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
['project_id', 'resolved_at_ms', 'dispatch_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO "${APPROVED_ACTION_RECOVERY_CONTROL_TABLE}"
|
||||
(dispatch_id, project_id, execution_version, status, version,
|
||||
next_scan_at_ms, lease_owner, lease_token, lease_expires_at_ms,
|
||||
finding_count, last_finding_mutation_id, last_finding,
|
||||
last_result_code, last_evidence_digest,
|
||||
resolution_mutation_id, created_at_ms, updated_at_ms)
|
||||
SELECT dispatch_id, project_id, version, 'armed', 0,
|
||||
lease_expires_at_ms, NULL, NULL, NULL,
|
||||
0, NULL, NULL, NULL, NULL, NULL, started_at_ms, started_at_ms
|
||||
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
||||
WHERE status = 'executing'`,
|
||||
{ transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user