Compare commits

..
3899 changed files with 1 additions and 1128856 deletions
-16
View File
@@ -1,16 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
-16
View File
@@ -1,16 +0,0 @@
GRPC_PORT=5500
BACK_PORT=5700
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
LOG_LEVEL='info'
JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
+1
View File
@@ -0,0 +1 @@
Gitee Pull mirror notifications.
-42
View File
@@ -1,42 +0,0 @@
name: "\U0001F41E Bug report"
description: Create a report to help us improve
body:
- type: input
id: version
attributes:
label: Qinglong version
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to reproduce
description: |
What do we need to do after opening your repro in order to make the bug happen? Clear and concise reproduction instructions are important for us to be able to triage your issue in a timely manner. Note that you can use [Markdown](https://guides.github.com/features/mastering-markdown/) to format lists and code.
placeholder: Steps to reproduce
validations:
required: true
- type: textarea
id: expected
attributes:
label: What is expected?
validations:
required: true
- type: textarea
id: actually-happening
attributes:
label: What is actually happening?
validations:
required: true
- type: textarea
id: system-info
attributes:
label: System Info
description: Output of `npx envinfo --system --binaries --browsers`
render: shell
placeholder: System, Binaries, Browsers
- type: textarea
id: additional-comments
attributes:
label: Any additional comments?
description: e.g. some background/context of how you ran into this bug.
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Telegram Chat
url: https://t.me/jiao_long
about: Ask questions and discuss with other Qinglong users in real time.
- name: Questions & Discussions
url: https://github.com/whyour/qinglong/discussions/new?category=q-a
about: Use GitHub discussions for message-board style questions and discussions.
@@ -1,40 +0,0 @@
name: "\U0001F680 New feature proposal"
description: Suggest an idea for this project
labels: [":sparkles: feature request"]
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: textarea
id: feature-description
attributes:
label: Clear and concise description of the problem
description: "Explain your use case, context, and rationale behind this feature request. More importantly, what is the **end user experience** you are trying to build that led to the need for this feature?"
validations:
required: true
- type: textarea
id: suggested-solution
attributes:
label: Suggested solution
description: "In module [xy] we could provide following implementation..."
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative
description: Clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Any other context or screenshots about the feature request here.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
required: true
-31
View File
@@ -1,31 +0,0 @@
## PR Type
What kind of change does this PR introduce?
<!-- Please check the one that applies to this PR using "x". -->
- [ ] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Other... Please describe:
## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->
Issue Number: N/A
## What is the new behavior?
## Does this PR introduce a breaking change?
- [ ] Yes
- [ ] No
<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->
## Other information
-4
View File
@@ -1,4 +0,0 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
-7
View File
@@ -1,7 +0,0 @@
# Comment to be posted to on PRs from first time contributors in your repository
newPRWelcomeComment: |
💖 Thanks for opening this pull request! 💖
Please be patient and we will get back to you as soon as we can.
# Comment to be posted to on pull requests merged by a first time user
firstPRMergeComment: >
Congrats on merging your first pull request! 🎉🎉🎉
-457
View File
@@ -1,457 +0,0 @@
name: Build And Push Docker Image
on:
push:
paths-ignore:
- "*.md"
branches:
- "master"
- "develop"
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
code_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to GitLab
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 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
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:
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:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
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_gitlab:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static to GitLab
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 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"
static_gitee:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static 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 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
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"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
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]
runs-on: ubuntu-latest
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
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
@@ -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
-40
View File
@@ -1,40 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/packages/*/node_modules
/packages/*/dist
/npm-debug.log*
/yarn-error.log
/yarn.lock
/package-lock.json
# production
/static
/.ql3-panel-dist
/data
# misc
.DS_Store
# umi
/src/.umi
/src/.umi-production
/src/.umi-test
/.env.local
.env
.history
.version.ts
/.tmp
__pycache__
/shell/preload/env.*
/shell/preload/notify.*
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
.deepseek/
.claude/
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
-22
View File
@@ -1,22 +0,0 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
-2
View File
@@ -1,2 +0,0 @@
strict-peer-dependencies=false
resolve-peers-from-workspace-root=false
-24
View File
@@ -1,24 +0,0 @@
**/*.md
**/*.svg
**/*.ejs
**/*.html
/.umi
/.umi-production
/.umi-test
/.history
/.tmp
/node_modules
npm-debug.log*
yarn-error.log
yarn.lock
package-lock.json
/static
/data
DS_Store
/src/.umi
/src/.umi-production
/src/.umi-test
.env.local
.env
version.ts
/.tmp
-11
View File
@@ -1,11 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"overrides": [
{
"files": ".prettierrc",
"options": { "parser": "json" }
}
]
}
-44
View File
@@ -1,44 +0,0 @@
import { defineConfig } from '@umijs/max';
const CompressionPlugin = require('compression-webpack-plugin');
const baseUrl = process.env.QlBaseUrl || '/';
export default defineConfig({
hash: true,
jsMinifier: 'terser',
antd: {},
locale: {
antd: true,
title: true,
baseNavigator: true,
},
outputPath: 'static/dist',
fastRefresh: true,
favicons: [`https://qn.whyour.cn/favicon.svg`],
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: {
[`${baseUrl}api`]: {
target: 'http://127.0.0.1:5700/',
changeOrigin: true,
ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
},
},
chainWebpack: ((config: any) => {
config.plugin('compression-webpack-plugin').use(
new CompressionPlugin({
algorithm: 'gzip',
test: new RegExp('\\.(js|css)$'),
threshold: 10240,
minRatio: 0.6,
}),
);
}) as any,
headScripts: [`./api/env.js`],
copy: [
{
from: 'node_modules/monaco-editor/min/vs',
to: 'static/dist/monaco-editor/min/vs',
},
],
npmClient: 'pnpm',
});
-43
View File
@@ -1,43 +0,0 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
| `gitnexus://repo/qinglong/clusters` | All functional areas |
| `gitnexus://repo/qinglong/processes` | All execution flows |
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 WHYOUR <https://github.com/whyour>.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-112
View File
@@ -1,112 +0,0 @@
<div align="center">
<img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<h1 align="center">Qinglong</h1>
[简体中文](./README.md) | English
Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
## Features
- Support for multiple scripting languages (python3, javaScript, shell, typescript)
- Support online management of scripts, environment variables, configuration files
- Support online view task log
- Support second-level task setting
- Support system level notification
- Support dark mode
- Support cell phone operation
## Version
### docker
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
```bash
npm i @whyour/qinglong
```
## Deployment
[View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
## Built-in API
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
## Built-in commands
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Development
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
Open your browser and visit <http://127.0.0.1:5700>
## Links
- [nevinee](https://gitee.com/evine)
- [crontab-ui](https://github.com/alseambusher/crontab-ui)
- [Ant Design](https://ant.design)
- [Ant Design Pro](https://pro.ant.design/)
- [Umijs](https://umijs.org)
- [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server)
## Name Origin
The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings".
In the Book of the Later Han Dynasty (後漢書-律曆志下), it is written: "The sun is in the sky, a cold and a summer, the four seasons are ready, all things are changed, the regency moves, and the green dragon moves to the star, which is called the year. (The Year of the Star)
Among the [twenty-eight Chinese constellations](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF), the Green Dragon is the generic name for the seven eastern constellations (Horn, Hyper, Diao, Fang, Heart, Tail and Minchi). It is known in Taoism as "Mengzhang" and in different Taoist scriptures as "Dijun", "Shengjian", "Shenjian" and He is also known in different Daoist scriptures as "Dijun", "Shengjun", "Shenjun" and "Ghost Catcher"[1], and is the guardian deity of Daoism, together with the White Tiger Supervisor of Soldiers.
-114
View File
@@ -1,114 +0,0 @@
<div align="center">
<img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<h1 align="center">青龙</h1>
简体中文 | [English](./README-en.md)
支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台
Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
## 功能
- 支持多种脚本语言(python3、javaScript、shell、typescript)
- 支持在线管理脚本、环境变量、配置文件
- 支持在线查看任务日志
- 支持秒级任务设置
- 支持系统级通知
- 支持暗黑模式
- 支持手机端操作
## 版本
### docker
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
```bash
npm i @whyour/qinglong
```
## 部署
[查看文档](https://qinglong.online/guide/getting-started/installation-guide)
## 内置 API
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
## 内置命令
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 开发
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
打开你的浏览器,访问 <http://127.0.0.1:5700>
## 链接
- [nevinee](https://gitee.com/evine)
- [crontab-ui](https://github.com/alseambusher/crontab-ui)
- [Ant Design](https://ant.design)
- [Ant Design Pro](https://pro.ant.design/)
- [Umijs](https://umijs.org)
- [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server)
## 名称来源
青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。
《后汉书·律历志下》记载:日周于天,一寒一暑,四时备成,万物毕改,摄提迁次,青龙移辰,谓之岁。
在中国[二十八宿](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF)中,青龙是东方七宿(角、亢、氐、房、心、尾、箕)的总称。 在早期星宿信仰中,祂是最尊贵的天神。 但被道教信仰吸纳入其神系后,神格大跌,道教将其称为“孟章”,在不同的道经中有“帝君”、“圣将”、“神将”和“捕鬼将”等称呼,与白虎监兵神君一起,是道教的护卫天神。
-5
View File
@@ -1,5 +0,0 @@
## Reporting a Vulnerability
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
-110
View File
@@ -1,110 +0,0 @@
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 { 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';
const route = Router();
export default (app: Router) => {
app.use('/configs', route);
route.get(
'/samples',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({
code: 200,
data: SAMPLE_FILES,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/files',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const fileList = await fs.readdir(config.configPath, 'utf-8');
res.send({
code: 200,
data: fileList
.filter((x) => !config.blackFileList.includes(x))
.map((x) => {
return { title: x, value: x };
}),
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.query.path as string, res);
} catch (e) {
return next(e);
}
},
);
route.post(
'/save',
celebrate({
body: Joi.object({
name: Joi.string().required(),
content: Joi.string().allow('').optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
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');
}
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))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
res.send({ code: 200, message: t('保存成功') });
} catch (e) {
return next(e);
}
},
);
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);
}
},
);
};
-525
View File
@@ -1,525 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import { t } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/crons', route);
route.get(
'/views',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.list();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/views',
celebrate({
body: Joi.object({
name: Joi.string().required(),
sorts: Joi.array().optional().allow(null),
filters: Joi.array().optional(),
filterRelation: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/views',
celebrate({
body: Joi.object({
name: Joi.string().required(),
id: Joi.number().required(),
sorts: Joi.array().optional().allow(null),
filters: Joi.array().optional(),
filterRelation: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) {
return res.send({ code: 400, message: t('参数错误') });
} else {
const data = await cronViewService.update(req.body);
return res.send({ code: 200, data });
}
} catch (e) {
return next(e);
}
},
);
route.delete(
'/views',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/views/move',
celebrate({
body: Joi.object({
fromIndex: Joi.number().required(),
toIndex: Joi.number().required(),
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.move(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/views/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/views/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronViewService = Container.get(CronViewService);
const data = await cronViewService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.crontabs(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.find(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.post(
'/',
celebrate({
body: Joi.object(commonCronSchema),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/run',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.run(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/stop',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.stop(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
labels: Joi.array().items(Joi.string().required()),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.removeLabels(
req.body.ids,
req.body.labels,
);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
labels: Joi.array().items(Joi.string().required()),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/log',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
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);
return res.send({
code: 200,
data: result.content,
logStatus: result.status,
});
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
...commonCronSchema,
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/import',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.importCrontab();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/status',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
status: Joi.string().required(),
pid: Joi.string().optional().allow(null),
log_path: Joi.string().optional().allow(null),
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) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.status({
...req.body,
status: req.body.status ? parseInt(req.body.status) : undefined,
pid: req.body.pid ? parseInt(req.body.pid) : undefined,
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/instances',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
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,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
}
},
);
route.post(
'/:id/instances/:instanceId/stop',
celebrate({
params: Joi.object({
id: Joi.number().required(),
instanceId: Joi.number().required(),
}),
}),
async (
req: Request<{ id: number; instanceId: number }>,
res: Response,
next: NextFunction,
) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/logs',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.logs(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
};
-397
View File
@@ -1,397 +0,0 @@
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 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 },
});
if (existing) {
await CrontabStatModel.update(
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
fail_count: (existing.fail_count || 0) + isFail,
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
ref_id: Number(ref_id),
date: today,
run_count: 1,
success_count: isSuccess,
fail_count: isFail,
total_time: elapsedMs,
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,
}),
]);
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: {
total,
enabled,
disabled,
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = parseInt(req.query.days as string) || 7;
const dates: string[] = [];
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 dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
total: Number(r.total_runs) || 0,
success: Number(r.total_success) || 0,
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 ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
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 ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
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),
runCount: Number(r.run_count),
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)
: '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: {
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
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,
})
: [];
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);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
res.send({
code: 200,
data: {
runningCount: running.length,
queuedCount: queuedCrons.length,
running,
idleTasks: idleTasks.map((c: any) => ({
id: c.id,
name: c.name || c.command || tf('任务#%s', c.id),
lastRun: c.last_execution_time
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
: '-',
})),
},
});
} catch (e) {
next(e);
}
},
);
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 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 }> = {};
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)))]
: [];
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 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
labelMap[label].success += Number(st.success_count) || 0;
labelMap[label].totalTime += Number(st.total_time) || 0;
}
});
});
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',
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) => {
try {
const memUsage = process.memoryUsage();
res.send({
code: 200,
data: {
platform: os.platform(),
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
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))),
cpus: os.cpus().length,
},
});
} catch (e) {
next(e);
}
},
);
};
-158
View File
@@ -1,158 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import DependenceService from '../services/dependence';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
const route = Router();
export default (app: Router) => {
app.use('/dependencies', route);
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.post(
'/',
celebrate({
body: Joi.array().items(
Joi.object({
name: Joi.string().required(),
type: Joi.number().required(),
remark: Joi.string().optional().allow(''),
}),
),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
name: Joi.string().required(),
id: Joi.number().required(),
type: Joi.number().required(),
remark: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/force',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body, true);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/reinstall',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/cancel',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
await dependenceService.cancel(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
};
-311
View File
@@ -1,311 +0,0 @@
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: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
.required();
export default (app: Router) => {
app.use('/envs', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.envs(req.query.searchValue as string);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.post(
'/',
celebrate({
body: Joi.array().items(
Joi.object({
value: Joi.string().required(),
name: Joi.string()
.required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}),
),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
if (!req.body?.length) {
return res.send({ code: 400, message: t('参数不正确') });
}
const data = await envService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
value: Joi.string().required(),
name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/:id/move',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
body: Joi.object({
fromIndex: Joi.number().required(),
toIndex: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.move(req.params.id, req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/name',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
name: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.updateNames(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
async (req: Request, res: Response, next: NextFunction) => {
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);
const data = Array.isArray(parseContent)
? parseContent
: [parseContent];
if (data.every((x) => x.name && x.value)) {
const result = await envService.create(
data.map((x) => ({
name: x.name,
value: x.value,
remarks: x.remarks,
labels: x.labels,
})),
);
return res.send({ code: 200, data: result });
} else {
return res.send({
code: 400,
message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
});
}
} catch (e) {
return next(e);
}
},
);
};
-27
View File
@@ -1,27 +0,0 @@
import { Router } from 'express';
import Logger from '../loaders/logger';
import { HealthService } from '../services/health';
import Container from 'typedi';
const route = Router();
export default (app: Router) => {
app.use('/', route);
route.get('/health', async (req, res) => {
try {
const healthService = Container.get(HealthService);
const health = await healthService.check();
res.status(200).send({
code: 200,
data: health,
});
} catch (err: any) {
Logger.error('Health check failed:', err);
res.status(500).send({
code: 500,
message: 'Health check failed',
error: err.message,
});
}
});
};
-33
View File
@@ -1,33 +0,0 @@
import { Router } from 'express';
import user from './user';
import env from './env';
import config from './config';
import log from './log';
import cron from './cron';
import script from './script';
import open from './open';
import dependence from './dependence';
import system from './system';
import subscription from './subscription';
import update from './update';
import dashboard from './dashboard';
import health from './health';
export default () => {
const app = Router();
user(app);
env(app);
config(app);
log(app);
cron(app);
script(app);
open(app);
dependence(app);
system(app);
subscription(app);
update(app);
dashboard(app);
health(app);
return app;
};
-153
View File
@@ -1,153 +0,0 @@
import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi';
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';
const route = Router();
const blacklist = ['.tmp'];
export default (app: Router) => {
app.use('/logs', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const result = await readDirs(config.logPath, config.logPath, blacklist);
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.query.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const logPath = `${req.query.path as string}/${req.query.file as string}`;
const runningInstance = await RunningInstanceModel.findOne({
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath);
res.send({
code: 200,
data: removeAnsi(content),
logStatus: runningInstance ? 'running' : undefined,
});
} catch (e) {
return next(e);
}
},
);
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);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(path, filename);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(finalPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
};
-120
View File
@@ -1,120 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import OpenService from '../services/open';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
const route = Router();
export default (app: Router) => {
app.use('/', route);
route.get(
'/apps',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const data = await openService.list();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/apps',
celebrate({
body: Joi.object({
name: Joi.string().optional().allow('').disallow('system'),
scopes: Joi.array().items(Joi.string().required()),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const data = await openService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/apps',
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const data = await openService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/apps',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const data = await openService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/apps/:id/reset-secret',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const data = await openService.resetSecret(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/auth/token',
celebrate({
query: {
client_id: Joi.string().required(),
client_secret: Joi.string().required(),
},
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const openService = Container.get(OpenService);
const result = await openService.authToken(req.query as any);
return res.send(result);
} catch (e) {
return next(e);
}
},
);
};
-425
View File
@@ -1,425 +0,0 @@
import {
fileExist,
isPathInside,
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 ScriptService from '../services/script';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((rootPath) =>
isPathInside(rootPath, resolved),
);
}
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: storage });
export default (app: Router) => {
app.use('/scripts', route);
route.get(
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
result = await readDir(
req.query.path as string,
config.scriptPath,
blacklist,
);
} else {
result = await readDirs(
config.scriptPath,
config.scriptPath,
blacklist,
(a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
},
);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().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.query.file as string,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).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,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, content, originFilename, directory } =
req.body as {
filename: string;
path: string;
content: string;
originFilename: string;
directory: string;
};
if (!path) {
path = config.scriptPath;
}
if (!path.endsWith('/')) {
path += '/';
}
if (!path.startsWith('/')) {
path = join(config.scriptPath, path);
}
if (
config.writePathList.every(
(rootPath) => !isPathInside(rootPath, path),
)
) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
if (req.file) {
const uploadPath = join(path, filename);
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
return res.send({ code: 200 });
}
if (directory) {
const dirPath = join(path, directory);
if (!isPathAllowed(dirPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(dirPath, { recursive: true });
return res.send({ code: 200 });
}
if (!originFilename) {
originFilename = filename;
}
const originFilePath = join(path, originFilename);
const filePath = join(path, filename);
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(path, { recursive: true });
const fileExists = await fileExist(filePath);
if (fileExists) {
await fs.copyFile(
originFilePath,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
if (filename !== originFilename) {
await rmPath(originFilePath);
}
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().required().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, content, path } = req.body as {
filename: string;
content: string;
path: string;
};
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(filePath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
route.put(
'/run',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
content: Joi.string().optional().allow(''),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/stop',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
pid: Joi.number().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`);
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
}, 3000);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/rename',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
newFilename: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, newFilename } = req.body as {
filename: string;
path: string;
newFilename: string;
};
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename);
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(filePath, newPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
};
-294
View File
@@ -1,294 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
const route = Router();
export default (app: Router) => {
app.use('/subscriptions', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.list(
req.query.searchValue as string,
req.query.ids as string,
);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.post(
'/',
celebrate({
body: Joi.object({
type: Joi.string().required(),
schedule: Joi.string().optional().allow('').allow(null),
interval_schedule: Joi.object({
type: Joi.string().required(),
value: Joi.number().min(1).required(),
})
.optional()
.allow('')
.allow(null),
name: Joi.string().optional().allow('').allow(null),
url: Joi.string().required(),
whitelist: Joi.string().optional().allow('').allow(null),
blacklist: Joi.string().optional().allow('').allow(null),
branch: Joi.string().optional().allow('').allow(null),
dependences: Joi.string().optional().allow('').allow(null),
pull_type: Joi.string().optional().allow('').allow(null),
pull_option: Joi.object().optional().allow('').allow(null),
extensions: Joi.string().optional().allow('').allow(null),
sub_before: Joi.string().optional().allow('').allow(null),
sub_after: Joi.string().optional().allow('').allow(null),
schedule_type: Joi.string().required(),
alias: Joi.string().required(),
proxy: Joi.string().optional().allow('').allow(null),
autoAddCron: Joi.boolean().optional().allow('').allow(null),
autoDelCron: Joi.boolean().optional().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
},
);
route.put(
'/run',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.run(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/stop',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.stop(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/log',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
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 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
type: Joi.string().required(),
schedule: Joi.string().optional().allow('').allow(null),
interval_schedule: Joi.object().optional().allow('').allow(null),
name: Joi.string().optional().allow('').allow(null),
url: Joi.string().required(),
whitelist: Joi.string().optional().allow('').allow(null),
blacklist: Joi.string().optional().allow('').allow(null),
branch: Joi.string().optional().allow('').allow(null),
dependences: Joi.string().optional().allow('').allow(null),
pull_type: Joi.string().optional().allow('').allow(null),
pull_option: Joi.object().optional().allow('').allow(null),
schedule_type: Joi.string().optional().allow('').allow(null),
extensions: Joi.string().optional().allow('').allow(null),
sub_before: Joi.string().optional().allow('').allow(null),
sub_after: Joi.string().optional().allow('').allow(null),
alias: Joi.string().required(),
proxy: Joi.string().optional().allow('').allow(null),
autoAddCron: Joi.boolean().optional().allow('').allow(null),
autoDelCron: Joi.boolean().optional().allow('').allow(null),
id: Joi.number().required(),
}),
}),
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()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
query: Joi.object({
force: Joi.boolean().optional(),
t: Joi.number(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.remove(req.body, req.query);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
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.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/status',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
status: Joi.string().required(),
pid: Joi.string().optional(),
log_path: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.status({
...req.body,
status: parseInt(req.body.status),
pid: parseInt(req.body.pid) || '',
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/logs',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
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.logs(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
};
-498
View File
@@ -1,498 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import * as fs from 'fs/promises';
import config from '../config';
import SystemService from '../services/system';
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 dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.tmpPath);
},
filename: function (req, file, cb) {
cb(null, 'data.tgz');
},
});
const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/system', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getAuthInfo();
const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile);
const isInitialized = !isDefaultAuthInfo(authInfo);
res.send({
code: 200,
data: {
isInitialized,
version,
publishTime: dayjs(publishTime).unix(),
branch: process.env.QL_BRANCH || 'master',
changeLog,
changeLogLink,
},
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const data = await systemService.getSystemConfig();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/log-remove-frequency',
celebrate({
body: Joi.object({
logRemoveFrequency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLogRemoveFrequency(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/cron-concurrency',
celebrate({
body: Joi.object({
cronConcurrency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateCronConcurrency(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-proxy',
celebrate({
body: Joi.object({
dependenceProxy: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateDependenceProxy(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/node-mirror',
celebrate({
body: Joi.object({
nodeMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateNodeMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/python-mirror',
celebrate({
body: Joi.object({
pythonMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePythonMirror(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/linux-mirror',
celebrate({
body: Joi.object({
linuxMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateLinuxMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/update-check',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.checkUpdate();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/update',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateSystem();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/reload',
celebrate({
body: Joi.object({
type: Joi.string().optional().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/notify',
celebrate({
body: Joi.object({
title: Joi.string().required(),
content: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.notify(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/command-run',
celebrate({
body: Joi.object({
command: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
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 uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
res.setHeader('Content-type', 'application/octet-stream');
await systemService.run(
{ ...req.body, logPath },
{
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
},
onLog: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
},
},
);
} catch (e) {
return next(e);
}
},
);
route.put(
'/command-stop',
celebrate({
body: Joi.object({
command: Joi.string().optional(),
pid: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.stop(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.exportData(res, req.body.type);
} catch (e) {
return next(e);
}
},
);
route.put(
'/data/import',
upload.single('data'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.importData();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.get(
'/log',
celebrate({
query: {
startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(),
t: Joi.string().optional(),
},
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.getSystemLog(
res,
req.query as {
startTime?: string;
endTime?: string;
},
);
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.deleteSystemLog();
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/auth/reset',
celebrate({
body: Joi.object({
retries: Joi.number().optional(),
twoFactorActivated: Joi.boolean().optional(),
password: Joi.string().optional(),
username: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const result = await userService.resetAuthInfo(req.body);
if (result) {
return res.send(result);
}
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/timezone',
celebrate({
body: Joi.object({
timezone: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateTimezone(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/lang',
celebrate({
body: Joi.object({
lang: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLanguage(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/panel-title',
celebrate({
body: Joi.object({
panelTitle: Joi.string().max(100).allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePanelTitle(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
-51
View File
@@ -1,51 +0,0 @@
import { NextFunction, Request, Response, Router } from 'express';
import Container from 'typedi';
import Logger from '../loaders/logger';
import SystemService from '../services/system';
const route = Router();
export default (app: Router) => {
app.use('/update', route);
route.put(
'/reload',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem();
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('system');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/data',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('data');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
};
-268
View File
@@ -1,268 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import UserService from '../services/user';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { t } from '../shared/i18n';
import { isDemoEnv, getToken } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.uploadPath);
},
filename: function (req, file, cb) {
const ext = path.parse(file.originalname).ext;
const key = uuidV4();
cb(null, key + ext);
},
});
const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/user', route);
route.post(
'/login',
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
}),
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.login({ ...req.body }, req);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.post(
'/logout',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
if (isDemoEnv()) {
return res.send({ code: 450, message: t('未知错误') });
}
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
},
);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getAuthInfo();
res.send({
code: 200,
data: {
username: authInfo.username,
avatar: authInfo.avatar,
twoFactorActivated: authInfo.twoFactorActivated,
},
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/two-factor/init',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.initTwoFactor();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/two-factor/active',
celebrate({
body: Joi.object({
code: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.activeTwoFactor(req.body.code);
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/two-factor/deactivate',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.deactivateTwoFactor();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/two-factor/login',
celebrate({
body: Joi.object({
code: Joi.string().required(),
username: Joi.string().required(),
password: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.twoFactorLogin(req.body, req);
res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get(
'/login-log',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.getLoginLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/notification',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.getNotificationMode();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/notification',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const result = await userService.updateNotificationMode(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/init',
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().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('更新成功') });
} catch (e) {
return next(e);
}
},
);
route.put(
'/notification/init',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const result = await userService.updateNotificationMode(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/avatar',
upload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const result = await userService.updateAvatar(req.file!.filename);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
-360
View File
@@ -1,360 +0,0 @@
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 { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
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();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1);
}
}
private startMasterProcess() {
// 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)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
}
}
})
.catch((error) => {
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1);
}
}
private async startHttpService() {
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
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 });
const { bootstrapLegacyShadowStartupReconciliation } = await import(
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
);
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 });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
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)}`);
}
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.stopManualPrimaryRuntime();
await this.httpServerService?.shutdown();
await this.closeLegacyShadowCaptureEvidence();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
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();
app.start().catch((error) => {
Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1);
});
-89
View File
@@ -1,89 +0,0 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task';
export const QL_COMMAND = 'ql';
export const TASK_PREFIX = `${TASK_COMMAND} `;
export const QL_PREFIX = `${QL_COMMAND} `;
export const SAMPLE_FILES = [
{
title: 'config.sample.sh',
value: 'sample/config.sample.sh',
target: 'config.sh',
},
{
title: 'notify.js',
value: 'sample/notify.js',
target: 'data/scripts/sendNotify.js',
},
{
title: 'notify.py',
value: 'sample/notify.py',
target: 'data/scripts/notify.py',
},
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
21: 'wxPusherSpt',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
-7
View File
@@ -1,7 +0,0 @@
export function isInContainer(): boolean {
return process.env.QL_CONTAINER === 'true';
}
export function maybeSudo(cmd: string): string {
return isInContainer() ? `sudo ${cmd}` : cmd;
}
-150
View File
@@ -1,150 +0,0 @@
import { execSync } 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';
import Logger from '../loaders/logger';
export interface GrpcTlsConfig {
caCert: string;
serverCert: string;
serverKey: string;
clientCert: string;
clientKey: string;
}
const certDir = path.join(config.configPath, 'grpc');
const caKeyPath = path.join(certDir, 'ca.key');
const caCertPath = path.join(certDir, 'ca.crt');
const serverKeyPath = path.join(certDir, 'server.key');
const serverCertPath = path.join(certDir, 'server.crt');
const clientKeyPath = path.join(certDir, 'client.key');
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`);
}
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');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
};
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`);
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`,
);
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`);
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 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) {
await cleanup();
throw e;
}
}
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
}
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
const exists = await Promise.all([
fileExist(caCertPath),
fileExist(serverCertPath),
fileExist(serverKeyPath),
fileExist(clientCertPath),
fileExist(clientKeyPath),
]);
if (exists.some((e) => !e)) {
return null;
}
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
fs.readFile(caCertPath, 'utf-8'),
fs.readFile(serverCertPath, 'utf-8'),
fs.readFile(serverKeyPath, 'utf-8'),
fs.readFile(clientCertPath, 'utf-8'),
fs.readFile(clientKeyPath, 'utf-8'),
]);
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
return { caCert, serverCert, serverKey, clientCert, clientKey };
}
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
if (cachedConfig) {
return cachedConfig;
}
let tlsConfig = await loadExistingCerts();
if (!tlsConfig) {
tlsConfig = await generateAllCerts();
await saveCerts(tlsConfig);
}
cachedConfig = tlsConfig;
return tlsConfig;
}
export function getGrpcCerts(): GrpcTlsConfig | null {
return cachedConfig;
}
-68
View File
@@ -1,68 +0,0 @@
import { request as undiciRequest, Dispatcher } from 'undici';
type RequestBaseOptions = {
dispatcher?: Dispatcher;
json?: Record<string, any>;
form?: string;
headers?: Record<string, string>;
} & Omit<Dispatcher.RequestOptions<null>, 'origin' | 'path' | 'method'>;
type RequestOptionsWithOptions = RequestBaseOptions &
Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
type ResponseTypeMap = {
json: Record<string, any>;
text: string;
};
type ResponseTypeKey = keyof ResponseTypeMap;
async function request(
url: string,
options?: RequestOptionsWithOptions,
): Promise<Dispatcher.ResponseData<null>> {
const { json, form, body, headers = {}, ...rest } = options || {};
const finalHeaders = { ...headers } as Record<string, string>;
let finalBody = body;
if (json) {
finalHeaders['content-type'] = 'application/json';
finalBody = JSON.stringify(json);
} else if (form) {
finalBody = form;
delete finalHeaders['content-type'];
}
const res = await undiciRequest(url, {
method: 'POST',
headers: finalHeaders,
body: finalBody,
...rest,
});
return res;
}
async function post<T extends ResponseTypeKey = 'json'>(
url: string,
options?: RequestBaseOptions & { responseType?: T },
): Promise<ResponseTypeMap[T]> {
const resp = await request(url, { ...options, method: 'POST' });
const rawText = await resp.body.text();
if (options?.responseType === 'text') {
return rawText as ResponseTypeMap[T];
}
try {
return JSON.parse(rawText) as ResponseTypeMap[T];
} catch {
return rawText as ResponseTypeMap[T];
}
}
export const httpClient = {
post,
request,
};
-197
View File
@@ -1,197 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({
path: path.join(__dirname, '../../.env'),
});
interface Config {
port: number;
grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
jwt: {
secret: string;
expiresIn?: string;
};
cors: {
origin: string[];
methods: string[];
};
logs: {
level: string;
};
api: {
prefix: string;
};
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
origin: process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
};
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) {
let qlHomePath = path.join(__dirname, '../../');
if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../');
}
process.env.QL_DIR = qlHomePath.replace(/\/$/g, '');
}
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const shellPath = path.join(rootPath, 'shell/');
const preloadPath = path.join(shellPath, 'preload/');
const tmpPath = path.join(rootPath, '.tmp/');
const samplePath = path.join(rootPath, 'sample/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const repoPath = path.join(dataPath, 'repo/');
const bakPath = path.join(dataPath, 'bak/');
const logPath = path.join(dataPath, 'log/');
const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json');
const extraFile = path.join(configPath, 'extra.sh');
const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite');
const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml');
const dataTgzFile = path.join(tmpPath, 'data.tgz');
const shareShellFile = path.join(shellPath, 'share.sh');
const dependenceProxyFile = path.join(configPath, 'dependence-proxy.sh');
if (envFound.error) {
throw new Error("⚠️ Couldn't find .env file ⚠️");
}
export default {
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
dataTgzFile,
shareShellFile,
dependenceProxyFile,
configString,
logPath,
extraFile,
authConfigFile,
confBakDir,
crontabFile,
sampleFile,
confFile,
envFile,
jsEnvFile,
pyEnvFile,
jsNotifyFile,
pyNotifyFile,
langEnvFile,
dbPath,
uploadPath,
configPath,
scriptPath,
repoPath,
samplePath,
blackFileList: [
'auth.json',
'config.sh.sample',
'cookie.sh',
'crontab.list',
'dependence-proxy.sh',
'env.sh',
'env.js',
'env.py',
'token.json',
'grpc',
'__pycache__',
],
writePathList: [configPath, scriptPath],
bakPath,
apiWhiteList: [
'/api/user/login',
'/api/health',
'/open/auth/token',
'/api/user/two-factor/login',
'/api/system',
'/api/user/init',
'/api/user/notification/init',
'/open/user/login',
'/open/user/two-factor/login',
'/open/system',
'/open/user/init',
'/open/user/notification/init',
],
versionFile,
lastVersionFile,
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
-28
View File
@@ -1,28 +0,0 @@
import { Request, Response } from 'express';
import pick from 'lodash/pick';
let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`;
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`;
}
}
pickedEnv = picked as Record<string, string>;
return picked;
}
export function serveEnv(_req: Request, res: Response) {
res.type('.js');
res.send(
Object.entries(getPickedEnv())
.map(([k, v]) => `window.__ENV__${k}=${JSON.stringify(v)};`)
.join('\n'),
);
}
-84
View File
@@ -1,84 +0,0 @@
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
];
const ENGLISH = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const special = ['-', '_'];
const config = num.concat(english).concat(ENGLISH).concat(special);
const arr = [];
arr.push(getOne(num));
arr.push(getOne(english));
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
return newArr.join('');
}
-46
View File
@@ -1,46 +0,0 @@
import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil';
export function formatUrl(doc: Subscription) {
let url = doc.url;
let host = '';
if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias);
} else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any;
url = doc.url!.replace(host, `${username}:${password}@${host}`);
}
}
return { url, host };
}
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;
}
-806
View File
@@ -1,806 +0,0 @@
import * as fs from 'fs/promises';
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 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';
export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName);
if (_exsit) {
return await fs.readFile(fileName, 'utf8');
}
return '';
}
export function removeAnsi(text: string) {
return text.replace(/\x1b\[\d+m/g, '');
}
export async function getLastModifyFilePath(dir: string) {
let filePath = '';
const _exsit = await fileExist(dir);
if (_exsit) {
const arr = await fs.readdir(dir);
arr.forEach(async (item) => {
const fullpath = path.join(dir, item);
const stats = await fs.lstat(fullpath);
if (stats.isFile()) {
if (stats.mtimeMs >= 0) {
filePath = fullpath;
}
}
});
}
return filePath;
}
export function getToken(req: any) {
const { authorization = '' } = req.headers;
if (authorization && authorization.split(' ')[0] === 'Bearer') {
return (authorization as string)
.replace('Bearer ', '')
.replace('mobile-', '')
.replace('desktop-', '');
}
return '';
}
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua);
const testVs = (regexp: RegExp) =>
(ua.match(regexp) || [])
.toString()
.replace(/[^0-9|_.]/g, '')
.replace(/_/g, '.');
// 系统
let system = 'unknow';
if (testUa(/windows|win32|win64|wow32|wow64/g)) {
system = 'windows'; // windows系统
} else if (testUa(/macintosh|macintel/g)) {
system = 'macos'; // macos系统
} else if (testUa(/x11/g)) {
system = 'linux'; // linux系统
} else if (testUa(/android|adr/g)) {
system = 'android'; // android系统
} else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) {
system = 'ios'; // ios系统
} else if (testUa(/openharmony/g)) {
system = 'openharmony'; // openharmony系统
}
let platform = 'desktop';
if (system === 'windows' || system === 'macos' || system === 'linux') {
platform = 'desktop';
} else if (
system === 'android' ||
system === 'ios' ||
system === 'openharmony' ||
testUa(/mobile/g)
) {
platform = 'mobile';
}
return platform as 'mobile' | 'desktop';
}
export async function fileExist(file: any) {
try {
await fs.access(file);
return true;
} catch (error) {
return false;
}
}
export async function createFile(file: string, data: string = '') {
await fs.mkdir(path.dirname(file), { recursive: true });
await writeFileWithLock(file, data);
}
export async function handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
}
return absolutePath;
}
export async function concurrentRun(
fnList: Array<() => Promise<any>> = [],
max = 5,
) {
if (!fnList.length) return;
const replyList: any[] = []; // 收集任务执行结果
const startTime = new Date().getTime(); // 记录任务执行开始时间
// 任务执行程序
const schedule = async (index: number) => {
return new Promise(async (resolve) => {
const fn = fnList[index];
if (!fn) return resolve(null);
// 执行当前异步任务
const reply = await fn();
replyList[index] = reply;
// 执行完当前任务后,继续执行任务池的剩余任务
await schedule(index + max);
resolve(null);
});
};
// 任务池执行程序
const scheduleList = new Array(max)
.fill(0)
.map((_, index) => schedule(index));
// 使用 Promise.all 批量执行
const r = await Promise.all(scheduleList);
const cost = (new Date().getTime() - startTime) / 1000;
return replyList;
}
enum FileType {
'directory',
'file',
}
export interface IFile {
title: string;
key: string;
type: 'directory' | 'file';
parent: string;
createTime: number;
size?: number;
children?: IFile[];
}
export function dirSort(a: IFile, b: IFile): number {
if (a.type === 'file' && b.type === 'file') {
return b.createTime - a.createTime;
} else if (a.type === 'directory' && b.type === 'directory') {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
}
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);
}
export async function readDir(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
): Promise<IFile[]> {
const absoluteBaseDir = path.resolve(baseDir);
const absoluteDir = path.resolve(baseDir, dir);
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
return [];
}
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[] = [];
for (const file of files) {
const subPath = path.join(absoluteDir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
result.push({
title: file,
type: 'directory',
key,
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: [],
});
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result;
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
}
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, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || stderr;
} catch (error) {
return JSON.stringify(error);
}
}
export async function promiseExecSuccess(command: string): Promise<string> {
try {
const { stdout } = await promisify(exec)(command, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || '';
} catch (error) {
return '';
}
}
export function parseHeaders(headers: string) {
if (!headers) return {};
const parsed: any = {};
let key: string;
let val: string;
let i: number;
headers &&
headers.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim().toLowerCase();
val = line.substring(i + 1).trim();
if (!key) {
return;
}
parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val;
});
return parsed;
}
function parseString(
input: string,
valueFormatFn?: (v: string) => string,
): Record<string, string> {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches: Record<string, string> = {};
let match;
while ((match = regex.exec(input)) !== null) {
const [, key, value] = match;
const _key = key.trim();
if (!_key || matches[_key]) {
continue;
}
let _value = value.trim();
try {
_value = valueFormatFn ? valueFormatFn(_value) : _value;
const jsonValue = JSON.parse(_value);
matches[_key] = jsonValue;
} catch (error) {
matches[_key] = _value;
}
}
return matches;
}
export function parseBody(
body: string,
contentType:
| 'application/json'
| 'multipart/form-data'
| 'application/x-www-form-urlencoded'
| 'text/plain',
valueFormatFn?: (v: string) => string,
) {
if (contentType === 'text/plain' || !body) {
return valueFormatFn && body ? valueFormatFn(body) : body;
}
const parsed = parseString(body, valueFormatFn);
switch (contentType) {
case 'multipart/form-data':
return Object.keys(parsed).reduce((p, c) => {
p.append(c, parsed[c]);
return p;
}, new FormData());
case 'application/x-www-form-urlencoded':
return Object.keys(parsed).reduce((p, c) => {
return p ? `${p}&${c}=${parsed[c]}` : `${c}=${parsed[c]}`;
});
}
return parsed;
}
export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, children) => {
if (err) {
reject(err);
}
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
});
});
}
export async function killTask(pid: number) {
const pids = await psTree(pid);
if (pids.length) {
try {
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} else {
process.kill(pid, 2);
}
}
export async function getPid(cmd: string) {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
const pid = await promiseExec(taskCommand);
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
changeLog: string;
publishTime: string;
}
export async function parseVersion(path: string): Promise<IVersion> {
return load(await fs.readFile(path, 'utf8')) as IVersion;
}
export function parseContentVersion(content: string): IVersion {
return load(content) as IVersion;
}
export async function getUniqPath(
command: string,
id: string,
): Promise<string> {
let suffix = '';
if (/^\d+$/.test(id)) {
suffix = `_${id}`;
}
let items = command.split(/ +/);
const maxTimeCommandIndex = items.findIndex((x) => x === '-m');
if (maxTimeCommandIndex !== -1) {
items = items.slice(maxTimeCommandIndex + 2);
}
let str = items[0];
if (items[0] === TASK_COMMAND) {
str = items[1];
}
const dotIndex = str.lastIndexOf('.');
if (dotIndex !== -1) {
str = str.slice(0, dotIndex);
}
const slashIndex = str.lastIndexOf('/');
let tempStr = '';
if (slashIndex !== -1) {
tempStr = str.slice(0, slashIndex);
const _slashIndex = tempStr.lastIndexOf('/');
if (_slashIndex !== -1) {
tempStr = tempStr.slice(_slashIndex + 1);
}
str = `${tempStr}_${str.slice(slashIndex + 1)}`;
}
return `${str}${suffix}`;
}
export function safeJSONParse(value?: string) {
if (!value) {
return {};
}
try {
return JSON.parse(value);
} catch (error) {
Logger.error('[safeJSONParse error]', error);
return {};
}
}
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export async function rmPath(path: string) {
try {
const _exsit = await fileExist(path);
if (_exsit) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
}
} catch (error) {
Logger.error('[rmPath error]', error);
}
}
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid timezone');
}
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true;
} catch (error) {
Logger.error('[setSystemTimezone error]', error);
return false;
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
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}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
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'),
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
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'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
}
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
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,
): 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`;
}
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.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
-96
View File
@@ -1,96 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Crontab {
name?: string;
command: string;
schedule?: string;
timestamp?: string;
saved?: boolean;
id?: number;
status?: CrontabStatus;
isSystem?: 1 | 0;
pid?: number;
isDisabled?: 1 | 0;
log_path?: string;
isPinned?: 1 | 0;
labels?: string[];
last_running_time?: number;
last_execution_time?: number;
sub_id?: number;
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
work_dir?: string;
constructor(options: Crontab) {
this.name = options.name;
this.command = options.command.trim();
this.schedule = options.schedule;
this.saved = options.saved;
this.id = options.id;
this.status =
typeof options.status === 'number' && CrontabStatus[options.status]
? options.status
: CrontabStatus.idle;
this.timestamp = new Date().toString();
this.isSystem = options.isSystem || 0;
this.pid = options.pid;
this.isDisabled = options.isDisabled || 0;
this.log_path = options.log_path || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
this.last_running_time = options.last_running_time || 0;
this.last_execution_time = options.last_execution_time || 0;
this.sub_id = options.sub_id;
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
this.work_dir = options.work_dir;
}
}
export enum CrontabStatus {
'running' = 0,
'idle' = 1,
'disabled' = 2,
'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,
});
-71
View File
@@ -1,71 +0,0 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class CrontabStat {
id?: number;
ref_id!: number;
date!: string;
run_count?: number;
success_count?: number;
fail_count?: number;
total_time?: number;
max_time?: number;
constructor(options: CrontabStat) {
this.id = options.id;
this.ref_id = options.ref_id;
this.date = options.date;
this.run_count = options.run_count || 0;
this.success_count = options.success_count || 0;
this.fail_count = options.fail_count || 0;
this.total_time = options.total_time || 0;
this.max_time = options.max_time || 0;
}
}
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'] },
],
},
);
-62
View File
@@ -1,62 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum CronViewType {
'系统' = 1,
'个人',
}
interface SortType {
type: 'ASC' | 'DESC';
value: string;
}
interface FilterType {
property: string;
operation: string;
value: string;
}
export class CrontabView {
name?: string;
id?: number;
position?: number;
isDisabled?: 1 | 0;
filters?: FilterType[];
sorts?: SortType[];
filterRelation?: 'and' | 'or';
type?: CronViewType;
constructor(options: CrontabView) {
this.name = options.name;
this.id = options.id;
this.position = options.position;
this.isDisabled = options.isDisabled || 0;
this.filters = options.filters;
this.sorts = options.sorts;
this.filterRelation = options.filterRelation;
this.type = options.type || CronViewType.个人;
}
}
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,
},
);
-63
View File
@@ -1,63 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Dependence {
timestamp?: string;
id?: number;
status: DependenceStatus;
type: DependenceTypes;
name: string;
log?: string[];
remark?: string;
constructor(options: Dependence) {
this.id = options.id;
this.status =
typeof options.status === 'number' && DependenceStatus[options.status]
? options.status
: DependenceStatus.queued;
this.type = options.type || DependenceTypes.nodejs;
this.timestamp = new Date().toString();
this.name = options.name.trim();
this.log = options.log || [];
this.remark = options.remark || '';
}
}
export enum DependenceStatus {
'installing',
'installed',
'installFailed',
'removing',
'removed',
'removeFailed',
'queued',
'cancelled',
}
export enum DependenceTypes {
'nodejs',
'python3',
'linux',
}
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,
},
);
-51
View File
@@ -1,51 +0,0 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class Env {
value?: string;
timestamp?: string;
id?: number;
status?: EnvStatus;
position?: number;
name?: string;
remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) {
this.value = options.value;
this.id = options.id;
this.status =
typeof options.status === 'number' && EnvStatus[options.status]
? options.status
: EnvStatus.normal;
this.timestamp = new Date().toString();
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
}
}
export enum EnvStatus {
'normal',
'disabled',
}
export const maxPosition = 9000000000000000;
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,
});
-23
View File
@@ -1,23 +0,0 @@
import { Sequelize, Transaction } from 'sequelize';
import config from '../config/index';
import { join } from 'path';
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 type ResponseType<T> = { code: number; data?: T; message?: string };
-198
View File
@@ -1,198 +0,0 @@
export enum NotificationMode {
'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot',
'serverChan' = 'serverChan',
'pushDeer' = 'pushDeer',
'bark' = 'bark',
'chat' = 'chat',
'telegramBot' = 'telegramBot',
'dingtalkBot' = 'dingtalkBot',
'weWorkBot' = 'weWorkBot',
'weWorkApp' = 'weWorkApp',
'aibotk' = 'aibotk',
'iGot' = 'iGot',
'pushPlus' = 'pushPlus',
'wePlusBot' = 'wePlusBot',
'email' = 'email',
'pushMe' = 'pushMe',
'feishu' = 'feishu',
'webhook' = 'webhook',
'chronocat' = 'Chronocat',
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
}
abstract class NotificationBaseInfo {
public type!: NotificationMode;
}
export class GotifyNotification extends NotificationBaseInfo {
public gotifyUrl = '';
public gotifyToken = '';
public gotifyPriority = 0;
}
export class GoCqHttpBotNotification extends NotificationBaseInfo {
public goCqHttpBotUrl = '';
public goCqHttpBotToken = '';
public goCqHttpBotQq = '';
}
export class ServerChanNotification extends NotificationBaseInfo {
public serverChanKey = '';
}
export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerKey = '';
public pushDeerUrl = '';
}
export class synologyChatNotification extends NotificationBaseInfo {
public synologyChatUrl = '';
}
export class BarkNotification extends NotificationBaseInfo {
public barkPush = '';
public barkIcon = 'https://qn.whyour.cn/logo.png';
public barkSound = '';
public barkGroup = 'qinglong';
public barkLevel = 'active';
public barkUrl = '';
public barkArchive = '';
}
export class TelegramBotNotification extends NotificationBaseInfo {
public telegramBotToken = '';
public telegramBotUserId = '';
public telegramBotProxyHost = '';
public telegramBotProxyPort = '';
public telegramBotProxyAuth = '';
public telegramBotApiHost = 'https://api.telegram.org';
}
export class DingtalkBotNotification extends NotificationBaseInfo {
public dingtalkBotToken = '';
public dingtalkBotSecret = '';
}
export class WeWorkBotNotification extends NotificationBaseInfo {
public weWorkBotKey = '';
public weWorkOrigin = '';
}
export class WeWorkAppNotification extends NotificationBaseInfo {
public weWorkAppKey = '';
public weWorkOrigin = '';
}
export class AibotkNotification extends NotificationBaseInfo {
public aibotkKey: string = '';
public aibotkType: 'room' | 'contact' = 'room';
public aibotkName: string = '';
}
export class IGotNotification extends NotificationBaseInfo {
public iGotPushKey = '';
}
export class PushPlusNotification extends NotificationBaseInfo {
public pushPlusToken = '';
public pushPlusUser = '';
public pushPlusTemplate = '';
public pushplusChannel = '';
public pushplusWebhook = '';
public pushplusCallbackUrl = '';
public pushplusTo = '';
}
export class WePlusBotNotification extends NotificationBaseInfo {
public wePlusBotToken = '';
public wePlusBotReceiver = '';
public wePlusBotVersion = '';
}
export class EmailNotification extends NotificationBaseInfo {
public emailService: string = '';
public emailUser: string = '';
public emailPass: string = '';
public emailTo: string = '';
}
export class PushMeNotification extends NotificationBaseInfo {
public pushMeKey: string = '';
public pushMeUrl: string = '';
}
export class ChronocatNotification extends NotificationBaseInfo {
public chronocatURL: string = '';
public chronocatQQ: string = '';
public chronocatToken: string = '';
}
export class WebhookNotification extends NotificationBaseInfo {
public webhookHeaders: string = '';
public webhookBody: string = '';
public webhookUrl: string = '';
public webhookMethod: 'GET' | 'POST' | 'PUT' = 'GET';
public webhookContentType:
| 'application/json'
| 'multipart/form-data'
| 'application/x-www-form-urlencoded' = 'application/json';
}
export class LarkNotification extends NotificationBaseInfo {
public larkKey = '';
public larkSecret = '';
}
export class NtfyNotification extends NotificationBaseInfo {
public ntfyUrl = '';
public ntfyTopic = '';
public ntfyPriority = '';
public ntfyToken = '';
public ntfyUsername = '';
public ntfyPassword = '';
public ntfyActions = '';
}
export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotAppToken = '';
public wxPusherBotTopicIds = '';
public wxPusherBotUids = '';
}
export class WxPusherSptNotification extends NotificationBaseInfo {
public wxPusherSptList = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export interface NotificationInfo
extends GoCqHttpBotNotification,
GotifyNotification,
ServerChanNotification,
PushDeerNotification,
synologyChatNotification,
BarkNotification,
TelegramBotNotification,
DingtalkBotNotification,
WeWorkBotNotification,
WeWorkAppNotification,
AibotkNotification,
IGotNotification,
PushPlusNotification,
WePlusBotNotification,
EmailNotification,
PushMeNotification,
WebhookNotification,
ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
-36
View File
@@ -1,36 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class App {
name: string;
scopes: AppScope[];
client_id: string;
client_secret: string;
tokens?: AppToken[];
id?: number;
constructor(options: App) {
this.name = options.name;
this.scopes = options.scopes;
this.client_id = options.client_id;
this.client_secret = options.client_secret;
this.id = options.id;
}
}
export interface AppToken {
value: string;
type?: 'Bearer';
expiration: number;
}
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,
});
-96
View File
@@ -1,96 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus {
'running' = 0,
'finished' = 1,
'stopped' = 2,
'error' = 3,
}
export interface RunningInstanceAttributes {
id?: number;
cron_id: number;
run_id?: string | null;
attempt_id?: string | null;
pid?: number;
log_path?: string;
started_at: number;
finished_at?: number;
status: InstanceStatus;
exit_code?: number;
}
export class RunningInstance {
id?: number;
cron_id!: number;
run_id?: string | null;
attempt_id?: string | null;
pid?: number;
log_path?: string;
started_at!: number;
finished_at?: number;
status!: InstanceStatus;
exit_code?: number;
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;
this.finished_at = options.finished_at;
this.status = options.status;
this.exit_code = options.exit_code;
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'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,
},
},
);
-40
View File
@@ -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);
-24
View File
@@ -1,24 +0,0 @@
export class SockMessage {
message?: string;
type?: SockMessageType;
references?: number[];
status?: number | string;
constructor(options: SockMessage) {
this.type = options.type;
this.message = options.message;
this.references = options.references;
this.status = options.status;
}
}
export type SockMessageType =
| 'ping'
| 'installDependence'
| 'uninstallDependence'
| 'updateSystemVersion'
| 'manuallyRunScript'
| 'runSubscriptionEnd'
| 'reloadSystem'
| 'updateNodeMirror'
| 'updateLinuxMirror';
-115
View File
@@ -1,115 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { SimpleIntervalSchedule } from 'toad-scheduler';
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
export class Subscription {
id?: number;
name?: string;
type?: 'public-repo' | 'private-repo' | 'file';
schedule_type?: 'crontab' | 'interval';
schedule?: string;
interval_schedule?: { type: SimpleIntervalScheduleUnit; value: number };
url?: string;
whitelist?: string;
blacklist?: string;
dependences?: string;
branch?: string;
status?: SubscriptionStatus;
pull_type?: 'ssh-key' | 'user-pwd';
pull_option?:
| { private_key: string }
| { username: string; password: string };
pid?: number;
is_disabled?: 1 | 0;
log_path?: string;
alias: string;
command?: string;
extensions?: string;
sub_before?: string;
sub_after?: string;
proxy?: string;
autoAddCron?: 1 | 0;
autoDelCron?: 1 | 0;
constructor(options: Subscription) {
this.id = options.id;
this.name = options.name || options.alias;
this.type = options.type;
this.schedule = options.schedule;
this.status = this.status =
typeof options.status === 'number' && SubscriptionStatus[options.status]
? options.status
: SubscriptionStatus.idle;
this.url = options.url;
this.whitelist = options.whitelist;
this.blacklist = options.blacklist;
this.dependences = options.dependences;
this.branch = options.branch;
this.pull_type = options.pull_type;
this.pull_option = options.pull_option;
this.pid = options.pid;
this.is_disabled = options.is_disabled;
this.log_path = options.log_path;
this.schedule_type = options.schedule_type;
this.alias = options.alias;
this.interval_schedule = options.interval_schedule;
this.extensions = options.extensions;
this.sub_before = options.sub_before;
this.sub_after = options.sub_after;
this.proxy = options.proxy;
this.autoAddCron = options.autoAddCron ? 1 : 0;
this.autoDelCron = options.autoDelCron ? 1 : 0;
}
}
export enum SubscriptionStatus {
'running',
'idle',
'disabled',
'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 },
},
);
-98
View File
@@ -1,98 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify';
export class SystemInfo {
ip?: string;
type: AuthDataType;
info?: SystemModelInfo;
id?: number;
constructor(options: SystemInfo) {
this.ip = options.ip;
this.info = options.info;
this.type = options.type;
this.id = options.id;
}
}
export enum LoginStatus {
'success',
'fail',
}
export enum AuthDataType {
'loginLog' = 'loginLog',
'authToken' = 'authToken',
'notification' = 'notification',
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
}
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number;
cronConcurrency?: number;
dependenceProxy?: string;
nodeMirror?: string;
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
timestamp?: number;
address?: string;
ip?: string;
platform?: string;
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
retries: number;
lastlogon: number;
lastip: string;
lastaddr: string;
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
}
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
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,
},
});
-13
View File
@@ -1,13 +0,0 @@
export enum ScheduleType {
BOOT = '@boot',
ONCE = '@once',
}
export type ScheduleValidator = (schedule?: string) => boolean;
export type CronSchedulerPayload = {
name: string;
id: string;
schedule: string;
command: string;
extra_schedules: Array<{ schedule: string }>;
};
-28
View File
@@ -1,28 +0,0 @@
import expressLoader from './express';
import depInjectorLoader from './depInjector';
import Logger from './logger';
import initData from './initData';
import { Application } from 'express';
import linkDeps from './deps';
import initTask from './initTask';
import initFile from './initFile';
export default async ({ app }: { app: Application }) => {
depInjectorLoader();
Logger.info('[boot] Dependency loaded');
await linkDeps();
Logger.info('[boot] Link deps loaded');
await initFile();
Logger.info('[boot] Init file loaded');
await initData();
Logger.info('[boot] Init data loaded');
initTask();
Logger.info('[boot] Init task loaded');
expressLoader({ app });
Logger.info('[boot] Express loaded');
};
-8
View File
@@ -1,8 +0,0 @@
import Container from 'typedi';
import CronService from '../services/cron';
export default async () => {
const cronService = Container.get(CronService);
await cronService.bootTask();
};
-31
View File
@@ -1,31 +0,0 @@
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';
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();
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
throw error;
}
};
-11
View File
@@ -1,11 +0,0 @@
import { Container } from 'typedi';
import LoggerInstance from './logger';
export default () => {
try {
Container.set('logger', LoggerInstance);
} catch (e) {
LoggerInstance.error('🔥 Error on dependency injector loader: %o', e);
throw e;
}
};
-51
View File
@@ -1,51 +0,0 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import config from '../config/index';
import Logger from './logger';
async function linkCommand() {
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [
{
src: 'update.sh',
dest: 'ql',
tmp: 'ql_tmp',
},
{
src: 'task.sh',
dest: 'task',
tmp: 'task_tmp',
},
];
for (const link of linkShell) {
const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest);
const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
}
export default async () => {
await linkCommand();
};
-225
View File
@@ -1,225 +0,0 @@
import express, { Request, Response, NextFunction, Application } from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import routes from '../api';
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 { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
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.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();
});
// 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(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,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
app.use((req: Request, res, next) => {
if (!req.headers) {
req.platform = 'desktop';
} else {
const platform = getPlatform(req.headers['user-agent'] || '');
req.platform = platform;
}
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);
}
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();
}
});
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name === 'UnauthorizedError') {
return res
.status(err.status)
.send({ code: 401, message: err.message })
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { errors: any[] },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name.includes('Sequelize')) {
return res
.status(500)
.send({
code: 400,
message: `${err.message}`,
errors: err.errors,
})
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
res.status(err.status || 500);
res.json({
code: err.status || 500,
message: err.message,
});
},
);
};
-249
View File
@@ -1,249 +0,0 @@
import DependenceService from '../services/dependence';
import { exec } from 'child_process';
import { Container } from 'typedi';
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';
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 OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
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);
const dependenceService = Container.get(DependenceService);
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 SystemModel.findOrCreate({
where: { type: AuthDataType.notification },
});
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
username: 'admin',
password: 'admin',
};
try {
const authFileExist = await fileExist(config.authConfigFile);
if (authFileExist) {
const content = await readFile(config.authConfigFile, 'utf8');
authInfo = safeJSONParse(content);
}
} catch (error) {
Logger.warn('Failed to read auth config file, using default credentials');
}
await SystemModel.upsert({
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(
{ 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({
pythonMirror: systemConfig.info?.pythonMirror,
});
}
if (systemConfig.info?.linuxMirror) {
systemService.updateLinuxMirror(
{
linuxMirror: systemConfig.info?.linuxMirror,
},
undefined,
() => installDependencies(),
);
} else {
installDependencies();
}
if (systemConfig.info?.nodeMirror) {
systemService.updateNodeMirror({
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%` }],
},
},
}).then((docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
exec(doc.command);
}
}
});
// 更新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\/%` },
],
},
},
}).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(
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update(
{
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 { }
// 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab();
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);
}
};
-129
View File
@@ -1,129 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import Logger from './logger';
import { fileExist } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
const rootPath = process.env.QL_DIR as string;
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const preloadPath = path.join(rootPath, 'shell/preload/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/');
const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
const sampleNotifyJsFile = path.join(samplePath, 'notify.js');
const sampleNotifyPyFile = path.join(samplePath, 'notify.py');
const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js');
const scriptNotifyPyFile = path.join(scriptPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const TaskBeforeFile = path.join(configPath, 'task_before.sh');
const TaskBeforeJsFile = path.join(configPath, 'task_before.js');
const TaskBeforePyFile = path.join(configPath, 'task_before.py');
const TaskAfterFile = path.join(configPath, 'task_after.sh');
const homedir = os.homedir();
const sshPath = path.resolve(homedir, '.ssh');
const sshdPath = path.join(dataPath, 'ssh.d');
const systemLogPath = path.join(dataPath, 'syslog');
const directories = [
configPath,
scriptPath,
preloadPath,
logPath,
tmpPath,
rootTmpPath,
uploadPath,
sshPath,
bakPath,
sshdPath,
systemLogPath,
];
const files = [
{
target: confFile,
source: sampleConfigFile,
checkExistence: true,
},
{
target: jsNotifyFile,
source: sampleNotifyJsFile,
checkExistence: false,
},
{
target: pyNotifyFile,
source: sampleNotifyPyFile,
checkExistence: false,
},
{
target: scriptNotifyJsFile,
source: sampleNotifyJsFile,
checkExistence: true,
},
{
target: scriptNotifyPyFile,
source: sampleNotifyPyFile,
checkExistence: true,
},
{
target: TaskBeforeFile,
source: sampleTaskShellFile,
checkExistence: true,
},
{
target: TaskBeforeJsFile,
content:
'// The JavaScript code that executes before the JavaScript task execution will execute.',
checkExistence: true,
},
{
target: TaskBeforePyFile,
content:
'# The Python code that executes before the Python task execution will execute.',
checkExistence: true,
},
{
target: TaskAfterFile,
source: sampleTaskShellFile,
checkExistence: true,
},
];
export default async () => {
for (const dirPath of directories) {
if (!(await fileExist(dirPath))) {
await fs.mkdir(dirPath);
}
}
for (const item of files) {
const exists = await fileExist(item.target);
if (!item.checkExistence || !exists) {
if (!item.content && !item.source) {
throw new Error(
`Neither content nor source specified for ${item.target}`,
);
}
const content =
item.content ||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content);
}
}
Logger.info('[boot] Init file down');
};
-75
View File
@@ -1,75 +0,0 @@
import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
import { t } from '../shared/i18n';
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}`;
}
const cron = {
id: NaN,
name: t('生成token'),
command: tokenCommand,
runOrigin: 'system',
} as ScheduleTaskType;
await scheduleService.cancelIntervalTask(cron);
scheduleService.createIntervalTask(
cron,
{
days: 28,
},
true,
);
// 运行删除日志任务
const data = await systemService.getSystemConfig();
if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = {
id: data.id as number,
name: t('删除日志'),
command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const,
};
await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(
rmlogCron,
{
days: data.info.logRemoveFrequency,
},
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);
}
};
-63
View File
@@ -1,63 +0,0 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import config from '../config';
import path from 'path';
const levelMap: Record<string, string> = {
info: 'ℹ️', // info图标
warn: '⚠️', // 警告图标
error: '❌', // 错误图标
debug: '🐛', // debug调试图标
};
const baseFormat = [
winston.format.splat(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.align(),
];
const consoleFormat = winston.format.combine(
winston.format.colorize({ level: true }),
...baseFormat,
winston.format.printf((info) => {
return `[${info.level} ${info.timestamp}]:${info.message}`;
}),
);
const plainFormat = winston.format.combine(
winston.format.uncolorize(),
...baseFormat,
winston.format.printf((info) => {
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
info.message
}`;
}),
);
const consoleTransport = new winston.transports.Console({
format: consoleFormat,
level: 'debug',
});
const fileTransport = new winston.transports.DailyRotateFile({
filename: path.join(config.systemLogPath, '%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '7d',
format: plainFormat,
level: config.logs.level || 'info',
});
const LoggerInstance = winston.createLogger({
level: 'debug',
levels: winston.config.npm.levels,
transports: [consoleTransport, fileTransport],
exceptionHandlers: [consoleTransport, fileTransport],
rejectionHandlers: [consoleTransport, fileTransport],
});
LoggerInstance.on('error', (error) => {
console.error('Logger error:', error);
});
export default LoggerInstance;
-16
View File
@@ -1,16 +0,0 @@
import { Server } from 'http';
import Logger from './logger';
import Sock from './sock';
export default async ({ server }: { server: Server }) => {
await Sock({ server });
Logger.info('[boot] Sock loaded');
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
Logger.error('Unhandled rejection:', reason, promise);
});
};
-41
View File
@@ -1,41 +0,0 @@
import sockJs from 'sockjs';
import { Server } from 'http';
import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
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 sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404');
}
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
conn.close('404');
});
echo.installHandlers(server);
};
-80
View File
@@ -1,80 +0,0 @@
import { Request, Response, NextFunction } from 'express';
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[] = [];
export const monitoringMiddleware = (
req: Request,
res: Response,
next: NextFunction,
) => {
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();
}
if (duration > 1000) {
Logger.warn(
`Slow request detected: ${req.method} ${
req.path
} took ${duration.toFixed(2)}ms`,
);
}
return originalEnd.call(this, chunk, encoding, cb);
};
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),
};
};
-84
View File
@@ -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] = {};
}
},
};
-319
View File
@@ -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 },
);
},
};
-110
View File
@@ -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 },
);
},
};
-143
View File
@@ -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 },
);
},
};
-115
View File
@@ -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,
},
);
},
};
-227
View File
@@ -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,
});
},
};
-208
View File
@@ -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 },
);
},
};
-436
View File
@@ -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,
});
},
};

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