Compare commits

..
1 Commits
Author SHA1 Message Date
whyour c083d2f4d1 更新版本 v2.17.0 2024-01-18 13:51:17 +08:00
3858 changed files with 13419 additions and 1095330 deletions
+10 -13
View File
@@ -1,16 +1,13 @@
GRPC_PORT=5500
BACK_PORT=5700
PUBLIC_PORT=5400
CRON_PORT=5500
BACK_PORT=5600
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='debug'
LOG_LEVEL='info'
SECRET='whyour'
JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
QINIU_AK=''
QINIU_SK=''
QINIU_SCOPE=''
TEMP=''
-4
View File
@@ -1,4 +0,0 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
-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
+174
View File
@@ -0,0 +1,174 @@
name: Build And Push Docker Image
on:
push:
paths-ignore:
- '.github/**'
- '*.md'
branches:
- 'master'
- 'develop'
tags:
- 'v*'
schedule:
- cron: '00 20 * * *'
workflow_dispatch:
jobs:
to_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitlab.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
to_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitee.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
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: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
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}
mkdir -p ~/.ssh
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git"
git remote add gitee "${REPO_GITEE}"
git remote add gitlab "${REPO_GITLAB}"
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH}
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH}
build:
needs: build-static
# 由于 ubuntu-latest 中使用 linux6.x 内核 linux/s390x npm 无法使用
runs-on: ubuntu-20.04
# runs-on: self-hosted
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: '8.3.1'
- uses: actions/setup-node@v4
with:
cache: 'pnpm'
- name: Setup timezone
uses: szenius/set-timezone@v1.2
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
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@v5
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: |
type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
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
# platforms: linux/amd64,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
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
@@ -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
+1 -16
View File
@@ -2,8 +2,6 @@
# dependencies
/node_modules
/packages/*/node_modules
/packages/*/dist
/npm-debug.log*
/yarn-error.log
/yarn.lock
@@ -11,7 +9,6 @@
# production
/static
/.ql3-panel-dist
/data
# misc
@@ -25,16 +22,4 @@
.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/
/.tmp
-22
View File
@@ -1,22 +0,0 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+1 -2
View File
@@ -1,2 +1 @@
strict-peer-dependencies=false
resolve-peers-from-workspace-root=false
strict-peer-dependencies=false
+12 -11
View File
@@ -2,22 +2,23 @@
**/*.svg
**/*.ejs
**/*.html
/.umi
/.umi-production
/.umi-test
/.history
/.tmp
/node_modules
package.json
.umi
.umi-production
.umi-test
.history
.tmp
node_modules
npm-debug.log*
yarn-error.log
yarn.lock
package-lock.json
/static
/data
static
data
DS_Store
/src/.umi
/src/.umi-production
/src/.umi-test
src/.umi
src/.umi-production
src/.umi-test
.env.local
.env
version.ts
+15 -3
View File
@@ -16,8 +16,13 @@ export default defineConfig({
favicons: [`https://qn.whyour.cn/favicon.svg`],
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: {
[`${baseUrl}api/public`]: {
target: 'http://127.0.0.1:5400/',
changeOrigin: true,
pathRewrite: { [`^${baseUrl}api/public`]: '/api' },
},
[`${baseUrl}api`]: {
target: 'http://127.0.0.1:5700/',
target: 'http://127.0.0.1:5600/',
changeOrigin: true,
ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
@@ -33,12 +38,19 @@ export default defineConfig({
}),
);
}) as any,
headScripts: [`./api/env.js`],
externals: {
react: 'window.React',
'react-dom': 'window.ReactDOM',
},
headScripts: [
`./api/env.js`,
'https://gw.alipayobjects.com/os/lib/react/18.2.0/umd/react.production.min.js',
'https://gw.alipayobjects.com/os/lib/react-dom/18.2.0/umd/react-dom.production.min.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 -->
+127 -31
View File
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[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)
[Demo](http://demo.dlww.cc: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)
[演示](http://demo.dlww.cc: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)
@@ -41,54 +41,150 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
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.
The npm version supports `debian/ubuntu/centos/alpine` systems and requires `node/python3` 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)
- task
```bash
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Use -- to split, -- followed by a parameter that is passed to the script, as in the following example, the script receives the parameter -u whyour -p password
task <file_path> -- -u whyour -p password
```
- ql
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
```
| **Parameter** | **Description** |
|---|---|
| file_url | Script address |
| repo_url | Repository address |
| whitelist | The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled |
| blacklist | Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled |
| dependence | Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist |
| extensions | Pull the branch of the repository |
| branch | Number of days of logs to be kept |
| days | File path for task execution |
| file_path | The name of the environment variable that needs to be concurrent or specified at the time of task execution |
## Deployment
### Docker (Recommended)
```bash
# curl -sSL get.docker.com | sh
docker run -dit \
-v $PWD/ql/data:/ql/data \
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
-p 5700:5700 \
# Deployment paths are not required, e.g. /test.
-e QlBaseUrl="/" \
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### Docker-compose (Recommended)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# start
docker-compose up -d
# stop
docker-compose down
```
### Podman (Recommended)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
-p 5700:5700 \
# Deployment paths are not required, e.g. /test.
-e QlBaseUrl="/" \
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### Local
It is recommended to use a pure system installation to avoid losing the original system data, you need to install node/npm/python3/pip3 yourself
```bash
npm install -g @whyour/qinglong
qinglong
# Add the environment variables QL_DIR and QL_DATA_DIR when prompted
export QL_DIR=""
export QL_DATA_DIR=""
# Run again
qinglong
```
## Development
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
$ git clone git@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
$ npm install -g pnpm@8.3.1
$ pnpm install
$ pnpm start
```
Open your browser and visit <http://127.0.0.1:5700>
+125 -31
View File
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[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)
[Demo](http://demo.dlww.cc: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)
[演示](http://demo.dlww.cc: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)
@@ -43,54 +43,148 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`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`
npm 版本支持 `debian/ubuntu/centos/alpine` 系统,需要自行安装 `node/python3`
```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)
- task
```bash
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 使用 -- 分割,-- 后面的参数会传给脚本,下面的例子,脚本就可接收到参数 -u whyour -p password
task <file_path> -- -u whyour -p password
```
- ql
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
```
| **参数** | **说明** |
|------------|---------------------------------------------------------------------------------------------|
| file_url | 脚本地址 |
| repo_url | 仓库地址 |
| whitelist | 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割 |
| blacklist | 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割 |
| dependence | 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割 |
| extensions | 拉取仓库的文件后缀,多个竖线分割 |
| branch | 拉取仓库的分支 |
| days | 需要保留的日志的天数 |
| file_path | 任务执行时的文件路径 |
## 部署
### docker (推荐)
```bash
# curl -sSL get.docker.com | sh
docker run -dit \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### docker-compose (推荐)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# 启动
docker-compose up -d
# 停止
docker-compose down
```
### podman (推荐)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### 本机
建议使用纯净系统安装,避免系统原有数据丢失,需要自己安装 node/npm/python3/pip3
```bash
npm install -g @whyour/qinglong
qinglong
# 根据提示增加环境变量 QL_DIR 和 QL_DATA_DIR
export QL_DIR=""
export QL_DATA_DIR=""
# 再次执行
qinglong
```
## 开发
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
$ git clone git@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
$ npm install -g pnpm@8.3.1
$ pnpm install
$ pnpm start
```
打开你的浏览器,访问 <http://127.0.0.1:5700>
-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.
+26 -36
View File
@@ -1,21 +1,20 @@
import { getFileContentByName, getLastModifyFilePath } 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 { join, basename } from 'path';
import { join } from 'path';
import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
import got from 'got';
const route = Router();
export default (app: Router) => {
app.use('/configs', route);
route.get(
'/samples',
'/sample',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({
@@ -51,9 +50,24 @@ export default (app: Router) => {
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.query.path as string, res);
let content = '';
const _path = req.query.path as string;
if (config.blackFileList.includes(_path) || !_path) {
res.send({ code: 403, message: '文件无法访问' });
}
if (_path.startsWith('sample/')) {
const res = await got.get(
`https://gitlab.com/whyour/qinglong/-/raw/master/${_path}`,
);
content = res.body;
} else if (_path.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, _path));
} else {
content = await getFileContentByName(join(config.configPath, _path));
}
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
@@ -72,36 +86,12 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) {
basePath = join(config.rootPath, 'data/scripts');
if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' });
}
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);
const path = join(config.configPath, name);
await fs.writeFile(path, content);
res.send({ code: 200, message: '保存成功' });
} catch (e) {
return next(e);
}
+40 -72
View File
@@ -4,10 +4,7 @@ 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';
import cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -62,7 +59,7 @@ export default (app: Router) => {
try {
const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) {
return res.send({ code: 400, message: t('参数错误') });
return res.send({ code: 400, message: '参数错误' });
} else {
const data = await cronViewService.update(req.body);
return res.send({ code: 200, data });
@@ -173,14 +170,27 @@ export default (app: Router) => {
route.post(
'/',
celebrate({
body: Joi.object(commonCronSchema),
body: Joi.object({
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
}),
}),
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 });
if (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -309,12 +319,8 @@ export default (app: Router) => {
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,
});
const data = await cronService.log(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
@@ -325,16 +331,30 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
...commonCronSchema,
labels: Joi.array().optional().allow(null),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
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 });
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -398,7 +418,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.importCrontab();
const data = await cronService.import_crontab();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -435,12 +455,6 @@ export default (app: Router) => {
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) => {
@@ -458,52 +472,6 @@ export default (app: Router) => {
},
);
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({
-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);
}
},
);
};
+17 -38
View File
@@ -8,28 +8,17 @@ 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.get('/', 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(
'/',
@@ -43,6 +32,7 @@ export default (app: Router) => {
),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body);
@@ -64,6 +54,7 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.update(req.body);
@@ -80,6 +71,7 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body);
@@ -96,6 +88,7 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body, true);
@@ -114,6 +107,7 @@ export default (app: Router) => {
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.getDb({ id: req.params.id });
@@ -130,6 +124,7 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body);
@@ -139,20 +134,4 @@ export default (app: Router) => {
}
},
);
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);
}
},
);
};
+8 -91
View File
@@ -1,13 +1,12 @@
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Router, Request, Response, NextFunction } from 'express';
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';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import fs from 'fs';
import { safeJSONParse } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -19,10 +18,6 @@ const storage = multer.diskStorage({
},
});
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);
@@ -49,7 +44,6 @@ export default (app: Router) => {
.required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}),
),
}),
@@ -57,9 +51,6 @@ export default (app: Router) => {
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) {
@@ -76,7 +67,6 @@ export default (app: Router) => {
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) => {
@@ -203,78 +193,6 @@ export default (app: Router) => {
},
);
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'),
@@ -293,14 +211,13 @@ export default (app: Router) => {
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 字段不能为空,参考导出文件格式'),
message: '文件缺少name或者value字段,参考导出文件格式',
});
}
} catch (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,
});
}
});
};
-6
View File
@@ -9,9 +9,6 @@ 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();
@@ -25,9 +22,6 @@ export default () => {
dependence(app);
system(app);
subscription(app);
update(app);
dashboard(app);
health(app);
return app;
};
+18 -97
View File
@@ -1,17 +1,11 @@
import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import * as fs from 'fs';
import config from '../config';
import { t } from '../shared/i18n';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { getFileContentByName, readDirs, rmPath } from '../config/util';
import { join } from 'path';
import { celebrate, Joi } from 'celebrate';
const route = Router();
const blacklist = ['.tmp'];
@@ -32,54 +26,20 @@ export default (app: Router) => {
}
});
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) => {
const logger: Logger = Container.get('logger');
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('暂无权限'),
});
if (blacklist.includes(req.path)) {
return res.send({ code: 403, message: '暂无权限' });
}
const content = await getFileContentByName(finalPath);
const filePath = join(
config.logPath,
(req.query.path || '') as string,
req.params.file,
);
const content = await getFileContentByName(filePath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
@@ -98,56 +58,17 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
let { filename, path, type } = req.body as {
filename: string;
path: string;
type: 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);
const filePath = join(config.logPath, path, filename);
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().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);
}
},
);
};
+80 -183
View File
@@ -1,10 +1,10 @@
import {
fileExist,
isPathInside,
getFileContentByName,
readDirs,
getLastModifyFilePath,
readDir,
rmPath,
IFile,
} from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
@@ -14,18 +14,9 @@ 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);
@@ -39,94 +30,59 @@ 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,
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
const targetPath = path.join(
config.scriptPath,
req.query.path as string,
);
result = await readDir(targetPath, 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: content });
} catch (e) {
return next(e);
}
},
);
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', 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) => {
const logger: Logger = Container.get('logger');
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
const filePath = join(
config.scriptPath,
req.query.path as string,
req.params.file,
);
const content = await getFileContentByName(filePath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
@@ -137,17 +93,8 @@ export default (app: Router) => {
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) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, content, originFilename, directory } =
req.body as {
@@ -167,44 +114,31 @@ export default (app: Router) => {
if (!path.startsWith('/')) {
path = join(config.scriptPath, path);
}
if (
config.writePathList.every(
(rootPath) => !isPathInside(rootPath, path),
)
) {
if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({
code: 403,
message: t('暂无权限'),
code: 430,
message: '文件路径禁止访问',
});
}
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);
await fs.rename(req.file.path, join(path, req.file.filename));
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 });
await fs.mkdir(join(path, directory), { 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 originFilePath = join(
path,
`${originFilename.replace(/\//g, '')}`,
);
const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath);
if (fileExists) {
await fs.copyFile(
@@ -215,7 +149,7 @@ export default (app: Router) => {
await rmPath(originFilePath);
}
}
await writeFileWithLock(filePath, content);
await fs.writeFile(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -233,21 +167,15 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
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);
const filePath = join(config.scriptPath, path, filename);
await fs.writeFile(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -260,27 +188,19 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
path: Joi.string().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path } = req.body as {
let { filename, path, type } = req.body as {
filename: string;
path: string;
type: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const filePath = join(config.scriptPath, path, filename);
await rmPath(filePath);
res.send({ code: 200 });
} catch (e) {
@@ -294,30 +214,24 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path } = req.body as {
let { filename } = 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('暂无权限'),
});
}
const filePath = join(config.scriptPath, filename);
// const stats = fs.statSync(filePath);
// res.set({
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件
// 'Content-Length': stats.size //文件大小
// });
// fs.createReadStream(filePath).pipe(res);
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
return next(err);
});
} catch (e) {
return next(e);
@@ -338,15 +252,9 @@ export default (app: Router) => {
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 || '');
await fs.writeFile(filePath, content || '', { encoding: 'utf8' });
const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath);
@@ -369,14 +277,8 @@ export default (app: Router) => {
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);
@@ -402,19 +304,14 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, newFilename } = req.body as {
let { filename, path, type, newFilename } = req.body as {
filename: string;
path: string;
type: 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) {
+5 -6
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -15,7 +15,6 @@ export default (app: Router) => {
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) {
@@ -60,7 +59,7 @@ export default (app: Router) => {
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
@@ -193,7 +192,7 @@ export default (app: Router) => {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
@@ -213,8 +212,8 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
query: Joi.object({
force: Joi.boolean().optional(),
t: Joi.number(),
}),
t: Joi.number()
})
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
+23 -170
View File
@@ -6,8 +6,6 @@ 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,
@@ -16,7 +14,6 @@ import {
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
@@ -36,11 +33,18 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getAuthInfo();
const authInfo = await userService.getUserInfo();
const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile);
const isInitialized = !isDefaultAuthInfo(authInfo);
let isInitialized = true;
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
res.send({
code: 200,
data: {
@@ -212,7 +216,7 @@ export default (app: Router) => {
'/reload',
celebrate({
body: Joi.object({
type: Joi.string().optional().allow('').allow(null),
type: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -269,22 +273,19 @@ export default (app: Router) => {
{
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);
res.write(`\n${message}`);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
res.write(message);
res.write(`\n${message}`);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, `\n${message}`);
},
},
);
@@ -315,15 +316,10 @@ export default (app: Router) => {
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);
await systemService.exportData(res);
} catch (e) {
return next(e);
}
@@ -344,155 +340,12 @@ export default (app: Router) => {
},
);
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);
}
},
);
route.get('/log', async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.getSystemLog(res);
} 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);
}
},
);
};
+7 -12
View File
@@ -8,8 +8,6 @@ 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({
@@ -57,8 +55,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const token = getToken(req);
await userService.logout(req.platform, token);
await userService.logout(req.platform);
res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -75,13 +72,11 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
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('更新成功') });
res.send({ code: 200, message: '更新成功' });
} catch (e) {
return next(e);
}
@@ -92,7 +87,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getAuthInfo();
const authInfo = await userService.getUserInfo();
res.send({
code: 200,
data: {
@@ -141,12 +136,12 @@ export default (app: Router) => {
);
route.put(
'/two-factor/deactivate',
'/two-factor/deactive',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.deactivateTwoFactor();
const data = await userService.deactiveTwoFactor();
res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -230,7 +225,7 @@ export default (app: Router) => {
try {
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
res.send({ code: 200, message: '更新成功' });
} catch (e) {
return next(e);
}
+18 -346
View File
@@ -1,360 +1,32 @@
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 'reflect-metadata'; // We need this in order to use @Decorators
import config from './config';
import express from 'express';
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;
}
async function startServer() {
const app = express();
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;
await require('./loaders/db').default();
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();
});
}
await require('./loaders/initFile').default();
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);
}
}
await require('./loaders/sentry').default({ expressApp: app });
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();
}
await require('./loaders/app').default({ expressApp: app });
const server = app
.listen(config.port, () => {
Logger.debug(`✌️ 后端服务启动成功!`);
console.debug(`✌️ 后端服务启动成功!`);
process.send?.('ready');
} catch (error) {
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
})
.on('error', (err) => {
Logger.error(err);
console.error(err);
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();
}
await require('./loaders/server').default({ server });
}
const app = new Application();
app.start().catch((error) => {
Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1);
});
startServer();
-64
View File
@@ -1,5 +1,3 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task';
@@ -25,65 +23,3 @@ export const SAMPLE_FILES = [
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,
};
+19 -98
View File
@@ -1,64 +1,13 @@
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'],
},
};
import { createRandomString } from './share';
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) {
// 声明QL_DIR环境变量
let qlHomePath = path.join(__dirname, '../../');
// 生产环境
if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../');
}
@@ -67,49 +16,23 @@ if (!process.env.QL_DIR) {
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 dataPath = path.join(rootPath, 'data/');
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 envFile = path.join(configPath, 'env.sh');
const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json');
@@ -118,6 +41,8 @@ const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite');
const authError = '错误的用户名密码,请重试';
const loginFaild = '请先登录!';
const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml');
const dataTgzFile = path.join(tmpPath, 'data.tgz');
@@ -129,9 +54,16 @@ if (envFound.error) {
}
export default {
...config,
jwt: config.jwt,
baseUrl,
port: parseInt(process.env.BACK_PORT as string, 10),
cronPort: parseInt(process.env.CRON_PORT as string, 10),
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
secret: process.env.SECRET || createRandomString(16, 32),
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
rootPath,
tmpPath,
dataPath,
@@ -139,6 +71,8 @@ export default {
shareShellFile,
dependenceProxyFile,
configString,
loginFaild,
authError,
logPath,
extraFile,
authConfigFile,
@@ -147,16 +81,10 @@ export default {
sampleFile,
confFile,
envFile,
jsEnvFile,
pyEnvFile,
jsNotifyFile,
pyNotifyFile,
langEnvFile,
dbPath,
uploadPath,
configPath,
scriptPath,
repoPath,
samplePath,
blackFileList: [
'auth.json',
@@ -165,17 +93,12 @@ export default {
'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',
@@ -192,6 +115,4 @@ export default {
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
+3 -3
View File
@@ -5,13 +5,13 @@ let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']);
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`;
picked.QlBaseUrl = `/${picked.QlBaseUrl}`
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`;
picked.QlBaseUrl = `${picked.QlBaseUrl}/`
}
}
pickedEnv = picked as Record<string, string>;
+1 -3
View File
@@ -32,9 +32,7 @@ export function formatCommand(doc: Subscription, url?: string) {
autoDelCron,
} = doc;
if (type === 'file') {
command += `raw "${_url}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
command += `raw "${_url}"`;
} else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
dependences || ''
+142 -430
View File
@@ -1,48 +1,18 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import got from 'got';
import iconv from 'iconv-lite';
import { exec } from 'child_process';
import FormData from 'form-data';
import psTreeFun from 'pstree.remy';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import { 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) {
@@ -51,10 +21,6 @@ export async function getFileContentByName(fileName: string) {
return '';
}
export function removeAnsi(text: string) {
return text.replace(/\x1b\[\d+m/g, '');
}
export async function getLastModifyFilePath(dir: string) {
let filePath = '';
@@ -64,7 +30,7 @@ export async function getLastModifyFilePath(dir: string) {
arr.forEach(async (item) => {
const fullpath = path.join(dir, item);
const stats = await fs.lstat(fullpath);
const stats = await fs.stat(fullpath);
if (stats.isFile()) {
if (stats.mtimeMs >= 0) {
filePath = fullpath;
@@ -86,6 +52,78 @@ export function getToken(req: any) {
return '';
}
export async function getNetIp(req: any) {
const ipArray = [
...new Set([
...(req.headers['x-real-ip'] || '').split(','),
...(req.headers['x-forwarded-for'] || '').split(','),
req.ip,
...req.ips,
req.socket.remoteAddress,
]),
].filter(Boolean);
let ip = ipArray[0];
if (ipArray.length > 1) {
for (let i = 0; i < ipArray.length; i++) {
const ipNumArray = ipArray[i].split('.');
const tmp = ipNumArray[0] + '.' + ipNumArray[1];
if (
tmp === '192.168' ||
(ipNumArray[0] === '172' &&
ipNumArray[1] >= 16 &&
ipNumArray[1] <= 32) ||
tmp === '10.7' ||
tmp === '127.0'
) {
continue;
}
ip = ipArray[i];
break;
}
}
ip = ip.substr(ip.lastIndexOf(':') + 1, ip.length);
if (ip.includes('127.0') || ip.includes('192.168') || ip.includes('10.7')) {
ip = '';
}
if (!ip) {
return { address: `获取失败`, ip };
}
try {
const csdnApi = got
.get(`https://searchplugin.csdn.net/api/v1/ip/get?ip=${ip}`, {
timeout: 10000,
retry: 0,
})
.text();
const pconlineApi = got
.get(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, {
timeout: 10000,
retry: 0,
})
.buffer();
const [csdnBody, pconlineBody] = await await Promise.all<any>([
csdnApi,
pconlineApi,
]);
const csdnRes = JSON.parse(csdnBody);
const pconlineRes = JSON.parse(iconv.decode(pconlineBody, 'GBK'));
let address = '';
if (csdnBody && csdnRes.code == 200) {
address = csdnRes.data.address;
} else if (pconlineRes && pconlineRes.addr) {
address = pconlineRes.addr;
}
return { address, ip };
} catch (error) {
return { address: `获取失败`, ip };
}
}
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua);
@@ -107,19 +145,12 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
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)
) {
} else if (system === 'android' || system === 'ios' || testUa(/mobile/g)) {
platform = 'mobile';
}
@@ -137,7 +168,7 @@ export async function fileExist(file: any) {
export async function createFile(file: string, data: string = '') {
await fs.mkdir(path.dirname(file), { recursive: true });
await writeFileWithLock(file, data);
await fs.writeFile(file, data);
}
export async function handleLogPath(
@@ -194,19 +225,19 @@ enum FileType {
'file',
}
export interface IFile {
interface IFile {
title: string;
key: string;
type: 'directory' | 'file';
parent: string;
createTime: number;
mtime: 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;
return b.mtime - a.mtime;
} else if (a.type === 'directory' && b.type === 'directory') {
return a.title.localeCompare(b.title);
} else {
@@ -226,7 +257,7 @@ export async function readDirs(
for (const file of files) {
const subPath = path.join(dir, file);
const stats = await fs.lstat(subPath);
const stats = await fs.stat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
@@ -240,7 +271,7 @@ export async function readDirs(
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
mtime: stats.mtime.getTime(),
children: children.sort(sort),
});
} else {
@@ -250,7 +281,7 @@ export async function readDirs(
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
mtime: stats.mtime.getTime(),
});
}
}
@@ -262,80 +293,23 @@ 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 relativePath = path.relative(baseDir, dir);
const files = await fs.readdir(dir);
const result: any = files
.filter((x) => !blacklist.includes(x))
.map(async (file: string) => {
const subPath = path.join(dir, file);
const stats = await fs.stat(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))
);
return {
title: file,
type: stats.isDirectory() ? 'directory' : 'file',
key,
parent: relativePath,
};
});
return result;
}
export async function promiseExec(command: string): Promise<string> {
@@ -366,9 +340,9 @@ export function parseHeaders(headers: string) {
if (!headers) return {};
const parsed: any = {};
let key: string;
let val: string;
let i: number;
let key;
let val;
let i;
headers &&
headers.split('\n').forEach(function parser(line) {
@@ -386,35 +360,6 @@ export function parseHeaders(headers: string) {
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:
@@ -422,13 +367,33 @@ export function parseBody(
| '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;
return body;
}
const parsed = parseString(body, valueFormatFn);
const parsed: any = {};
let key;
let val;
let i;
body &&
body.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim();
val = line.substring(i + 1).trim();
if (!key || parsed[key]) {
return;
}
try {
const jsonValue = JSON.parse(val);
parsed[key] = jsonValue;
} catch (error) {
parsed[key] = val;
}
});
switch (contentType) {
case 'multipart/form-data':
@@ -447,11 +412,11 @@ export function parseBody(
export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, children) => {
psTreeFun(pid, (err: any, pids: number[]) => {
if (err) {
reject(err);
}
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
resolve(pids.filter((x) => !isNaN(x)));
});
});
}
@@ -464,39 +429,18 @@ export async function killTask(pid: number) {
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} 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`;
export async function getPid(name: string) {
const taskCommand = `ps -eo pid,command | grep "${name}" | 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;
@@ -508,7 +452,7 @@ export async function parseVersion(path: string): Promise<IVersion> {
return load(await fs.readFile(path, 'utf8')) as IVersion;
}
export function parseContentVersion(content: string): IVersion {
export async function parseContentVersion(content: string): Promise<IVersion> {
return load(content) as IVersion;
}
@@ -516,18 +460,13 @@ 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);
id = `_${id}`;
} else {
id = '';
}
const items = command.split(/ +/);
let str = items[0];
if (items[0] === TASK_COMMAND) {
str = items[1];
@@ -551,7 +490,7 @@ export async function getUniqPath(
str = `${tempStr}_${str.slice(slashIndex + 1)}`;
}
return `${str}${suffix}`;
return `${str}${id}`;
}
export function safeJSONParse(value?: string) {
@@ -562,17 +501,11 @@ export function safeJSONParse(value?: string) {
try {
return JSON.parse(value);
} catch (error) {
Logger.error('[safeJSONParse error]', error);
Logger.error('[JSON.parse失败]', 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);
@@ -580,227 +513,6 @@ export async function rmPath(path: string) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
}
} catch (error) {
Logger.error('[rmPath error]', error);
Logger.error('[rmPath失败]', 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);
}
+3 -12
View File
@@ -21,9 +21,6 @@ export class Crontab {
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;
@@ -48,20 +45,17 @@ export class Crontab {
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,
'queued' = 0.5,
'idle' = 1,
'disabled' = 2,
'queued' = 3,
'disabled',
}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: {
unique: 'compositeIndex',
@@ -90,7 +84,4 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
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'] },
],
},
);
+18 -1
View File
@@ -32,7 +32,6 @@ export enum DependenceStatus {
'removed',
'removeFailed',
'queued',
'cancelled',
}
export enum DependenceTypes {
@@ -41,12 +40,30 @@ export enum DependenceTypes {
'linux',
}
export enum InstallDependenceCommandTypes {
'pnpm add -g',
'pip3 install --disable-pip-version-check --root-user-action=ignore',
'apk add --no-check-certificate',
}
export enum GetDependenceCommandTypes {
'pnpm ls -g ',
'pip3 show --disable-pip-version-check',
'apk info -es',
}
export enum versionDependenceCommandTypes {
'@',
'==',
'=',
}
export enum unInstallDependenceCommandTypes {
'pnpm remove -g',
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
'apk del',
}
export interface DependenceInstance
extends Model<Dependence, Dependence>,
Dependence {}
+2 -8
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env {
value?: string;
@@ -9,8 +9,6 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) {
this.value = options.value;
@@ -22,9 +20,7 @@ export class Env {
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 || [];
this.remarks = options.remarks;
}
}
@@ -46,6 +42,4 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});
+8 -54
View File
@@ -1,3 +1,5 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode {
'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot',
@@ -12,15 +14,11 @@ export enum NotificationMode {
'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 {
@@ -48,8 +46,9 @@ export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerUrl = '';
}
export class synologyChatNotification extends NotificationBaseInfo {
public synologyChatUrl = '';
export class ChatNotification extends NotificationBaseInfo {
public chatUrl = '';
public chatToken = '';
}
export class BarkNotification extends NotificationBaseInfo {
@@ -59,7 +58,6 @@ export class BarkNotification extends NotificationBaseInfo {
public barkGroup = 'qinglong';
public barkLevel = 'active';
public barkUrl = '';
public barkArchive = '';
}
export class TelegramBotNotification extends NotificationBaseInfo {
@@ -99,29 +97,16 @@ export class IGotNotification extends NotificationBaseInfo {
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 {
@@ -143,33 +128,6 @@ export class WebhookNotification extends NotificationBaseInfo {
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
@@ -177,7 +135,7 @@ export interface NotificationInfo
GotifyNotification,
ServerChanNotification,
PushDeerNotification,
synologyChatNotification,
ChatNotification,
BarkNotification,
TelegramBotNotification,
DingtalkBotNotification,
@@ -186,13 +144,9 @@ export interface NotificationInfo
AibotkNotification,
IGotNotification,
PushPlusNotification,
WePlusBotNotification,
EmailNotification,
PushMeNotification,
WebhookNotification,
ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
LarkNotification {}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs';
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
-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);
-2
View File
@@ -2,13 +2,11 @@ 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;
}
}
+4 -41
View File
@@ -2,13 +2,13 @@ import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify';
export class SystemInfo {
export class AuthInfo {
ip?: string;
type: AuthDataType;
info?: SystemModelInfo;
id?: number;
constructor(options: SystemInfo) {
constructor(options: AuthInfo) {
this.ip = options.ip;
this.info = options.info;
this.type = options.type;
@@ -27,20 +27,15 @@ export enum AuthDataType {
'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 {
@@ -51,43 +46,11 @@ export interface LoginLogInfo {
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>;
LoginLogInfo;
export interface SystemInstance
extends Model<SystemInfo, SystemInfo>,
SystemInfo {}
export interface SystemInstance extends Model<AuthInfo, AuthInfo>, AuthInfo { }
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
+7
View File
@@ -0,0 +1,7 @@
declare namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
declare module 'pstree.remy';
-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 }>;
};
+25 -19
View File
@@ -5,24 +5,30 @@ 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');
export default async ({ expressApp }: { expressApp: Application }) => {
try {
depInjectorLoader();
Logger.info('✌️ Dependency Injector loaded');
console.log('✌️ Dependency Injector loaded');
expressLoader({ app: expressApp });
Logger.info('✌️ Express loaded');
console.log('✌️ Express loaded');
await initData();
Logger.info('✌️ init data loaded');
console.log('✌️ init data loaded');
await linkDeps();
Logger.info('✌️ link deps loaded');
console.log('✌️ link deps loaded');
initTask();
Logger.info('✌️ init task loaded');
console.log('✌️ init task loaded');
} catch (error) {
Logger.error(`✌️ depInjectorLoader expressLoader initData linkDeps failed, ${error}`);
console.error(`✌️ depInjectorLoader expressLoader initData linkDeps failed ${error}`);
}
};
-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();
};
+118 -9
View File
@@ -1,14 +1,16 @@
import Logger from './logger';
import path from 'path';
import DataStore from 'nedb';
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 { fileExist } from '../config/util';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { runMigrations } from '../migrations/runner';
import config from '../config';
import { sequelize } from '../data';
export default async () => {
try {
@@ -19,13 +21,120 @@ export default async () => {
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
await runMigrations();
Logger.info('[boot] DB loaded');
// 初始化新增字段
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column proxy VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) { }
try {
await sequelize.query('alter table Crontabs add column extra_schedules JSON');
} catch (error) { }
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) { }
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) { }
// 2.10-2.11 升级
const cronDbFile = path.join(config.rootPath, 'db/crontab.db');
const envDbFile = path.join(config.rootPath, 'db/env.db');
const appDbFile = path.join(config.rootPath, 'db/app.db');
const authDbFile = path.join(config.rootPath, 'db/auth.db');
const dependenceDbFile = path.join(config.rootPath, 'db/dependence.db');
const crondbExist = await fileExist(cronDbFile);
const dependenceDbExist = await fileExist(dependenceDbFile);
const envDbExist = await fileExist(envDbFile);
const appDbExist = await fileExist(appDbFile);
const authDbExist = await fileExist(authDbFile);
const cronCount = await CrontabModel.count();
const dependenceCount = await DependenceModel.count();
const envCount = await EnvModel.count();
const appCount = await AppModel.count();
const authCount = await SystemModel.count();
if (crondbExist && cronCount === 0) {
const cronDb = new DataStore({
filename: cronDbFile,
autoload: true,
});
cronDb.persistence.compactDatafile();
cronDb.find({}).exec(async (err, docs) => {
await CrontabModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (dependenceDbExist && dependenceCount === 0) {
const dependenceDb = new DataStore({
filename: dependenceDbFile,
autoload: true,
});
dependenceDb.persistence.compactDatafile();
dependenceDb.find({}).exec(async (err, docs) => {
await DependenceModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (envDbExist && envCount === 0) {
const envDb = new DataStore({
filename: envDbFile,
autoload: true,
});
envDb.persistence.compactDatafile();
envDb.find({}).exec(async (err, docs) => {
await EnvModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (appDbExist && appCount === 0) {
const appDb = new DataStore({
filename: appDbFile,
autoload: true,
});
appDb.persistence.compactDatafile();
appDb.find({}).exec(async (err, docs) => {
await AppModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (authDbExist && authCount === 0) {
const authDb = new DataStore({
filename: authDbFile,
autoload: true,
});
authDb.persistence.compactDatafile();
authDb.find({}).exec(async (err, docs) => {
await SystemModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
console.log('✌️ DB loaded');
Logger.info('✌️ DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
throw error;
console.error('✌️ DB load failed');
Logger.error(error);
}
};
+27 -24
View File
@@ -1,51 +1,54 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar';
import config from '../config/index';
import Logger from './logger';
import { fileExist, promiseExec, rmPath } from '../config/util';
async function linkCommand() {
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
const source = path.join(config.rootPath, src);
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
const stats = await fs.lstat(target);
if (!stats) {
await fs.symlink(source, target, 'dir');
}
} catch (error) {}
}
async function linkCommandToDir(commandDir: string) {
async function linkCommand() {
const commandPath = await promiseExec('which node');
const commandDir = path.dirname(commandPath);
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);
await rmPath(target);
await fs.symlink(source, target);
}
}
export default async () => {
export default async (src: string = 'deps') => {
await linkCommand();
await linkToNodeModule(src);
const source = path.join(config.rootPath, src);
const watcher = chokidar.watch(source, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
});
watcher
.on('add', (path) => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src));
};
+63 -98
View File
@@ -3,63 +3,45 @@ 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 jwt, { UnauthorizedError } from 'express-jwt';
import fs from 'fs/promises';
import { getPlatform, getToken, safeJSONParse } from '../config/util';
import Container from 'typedi';
import OpenService from '../services/open';
import rewrite from 'express-urlrewrite';
import UserService from '../services/user';
import * as Sentry from '@sentry/node';
import { EnvModel } from '../data/env';
import { errors } from 'celebrate';
import { createProxyMiddleware } from 'http-proxy-middleware';
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';
import Logger from './logger';
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(
'/api/public',
createProxyMiddleware({
target: `http://0.0.0.0:${config.publicPort}/api`,
changeOrigin: true,
pathRewrite: { '/api/public': '' },
logProvider: () => Logger,
}),
);
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,
jwt({
secret: config.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
path: [...config.apiWhiteList, /^\/open\//],
}),
);
@@ -73,42 +55,26 @@ export default ({ app }: { app: Application }) => {
return next();
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
app.use(async (req, res, 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 (req.path.startsWith('/open/')) {
const openService = Container.get(OpenService);
const doc = await openService.findTokenByValue(headerToken);
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 keyMatch = req.path.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 (
doc.scopes.includes(key as any) &&
currentToken &&
currentToken.expiration >= Math.round(Date.now() / 1000)
) {
return next();
}
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}`;
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`;
if (
!headerToken &&
originPath &&
@@ -117,38 +83,40 @@ export default ({ app }: { app: Application }) => {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
const data = await fs.readFile(config.authConfigFile, 'utf8');
if (data && headerToken) {
const { token = '', tokens = {} } = safeJSONParse(data);
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? t('Token 已失效')
: t('请先登录');
? 'jwt malformed'
: 'No authorization token was found';
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)
) {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
return next();
}
const authInfo =
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo();
let isInitialized = !isDefaultAuthInfo(authInfo);
let isInitialized = true;
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
return res.send({ code: 450, message: '未知错误' });
} else {
return next();
}
@@ -157,15 +125,10 @@ export default ({ app }: { app: Application }) => {
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((req, res, next) => {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
});
app.use(errors());
@@ -199,8 +162,8 @@ export default ({ app }: { app: Application }) => {
.status(500)
.send({
code: 400,
message: `${err.message}`,
errors: err.errors,
message: `${err.name} ${err.message}`,
validation: err.errors,
})
.end();
}
@@ -215,6 +178,8 @@ export default ({ app }: { app: Application }) => {
res: Response,
next: NextFunction,
) => {
Sentry.captureException(err);
res.status(err.status || 500);
res.json({
code: err.status || 500,
+20 -135
View File
@@ -10,119 +10,11 @@ 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({
@@ -141,11 +33,21 @@ export default async () => {
// 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
DependenceModel.findAll({
where: {},
order: [
['type', 'DESC'],
['createdAt', 'DESC'],
],
raw: true,
}).then(async (docs) => {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
);
dependenceService.installDependenceOneByOne(docs);
});
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
@@ -189,7 +91,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/log/${doc.command.replace(
command: `${config.rootPath}/data/log/${doc.command.replace(
`${config.rootPath}/log/`,
'',
)}`,
@@ -200,7 +102,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/config/${doc.command.replace(
command: `${config.rootPath}/data/config/${doc.command.replace(
`${config.rootPath}/config/`,
'',
)}`,
@@ -211,7 +113,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/db/${doc.command.replace(
command: `${config.rootPath}/data/db/${doc.command.replace(
`${config.rootPath}/db/`,
'',
)}`,
@@ -223,27 +125,10 @@ export default async () => {
}
});
// 初始化语言(必须在 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);
}
// 初始化增加系统配置
await SystemModel.upsert({ type: AuthDataType.systemConfig });
};
+84 -95
View File
@@ -3,16 +3,9 @@ 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 dataPath = path.join(rootPath, 'data/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/');
@@ -20,110 +13,106 @@ 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 authConfigFile = path.join(configPath, 'auth.json');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleAuthFile = path.join(samplePath, 'auth.sample.json');
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);
}
const authFileExist = await fileExist(authConfigFile);
const confFileExist = await fileExist(confFile);
const scriptDirExist = await fileExist(scriptPath);
const logDirExist = await fileExist(logPath);
const configDirExist = await fileExist(configPath);
const uploadDirExist = await fileExist(uploadPath);
const sshDirExist = await fileExist(sshPath);
const bakDirExist = await fileExist(bakPath);
const sshdDirExist = await fileExist(sshdPath);
const systemLogDirExist = await fileExist(systemLogPath);
const tmpDirExist = await fileExist(tmpPath);
const scriptNotifyJsFileExist = await fileExist(scriptNotifyJsFile);
const scriptNotifyPyFileExist = await fileExist(scriptNotifyPyFile);
const TaskBeforeFileExist = await fileExist(TaskBeforeFile);
const TaskAfterFileExist = await fileExist(TaskAfterFile);
if (!configDirExist) {
await fs.mkdir(configPath);
}
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);
}
if (!scriptDirExist) {
await fs.mkdir(scriptPath);
}
Logger.info('[boot] Init file down');
if (!logDirExist) {
await fs.mkdir(logPath);
}
if (!tmpDirExist) {
await fs.mkdir(tmpPath);
}
if (!uploadDirExist) {
await fs.mkdir(uploadPath);
}
if (!sshDirExist) {
await fs.mkdir(sshPath);
}
if (!bakDirExist) {
await fs.mkdir(bakPath);
}
if (!sshdDirExist) {
await fs.mkdir(sshdPath);
}
if (!systemLogDirExist) {
await fs.mkdir(systemLogPath);
}
// 初始化文件
if (!authFileExist) {
await fs.writeFile(authConfigFile, await fs.readFile(sampleAuthFile));
}
if (!confFileExist) {
await fs.writeFile(confFile, await fs.readFile(sampleConfigFile));
}
if (!scriptNotifyJsFileExist) {
await fs.writeFile(
scriptNotifyJsFile,
await fs.readFile(sampleNotifyJsFile),
);
}
if (!scriptNotifyPyFileExist) {
await fs.writeFile(
scriptNotifyPyFile,
await fs.readFile(sampleNotifyPyFile),
);
}
if (!TaskBeforeFileExist) {
await fs.writeFile(TaskBeforeFile, await fs.readFile(sampleTaskShellFile));
}
if (!TaskAfterFileExist) {
await fs.writeFile(TaskAfterFile, await fs.readFile(sampleTaskShellFile));
}
Logger.info('✌️ Init file down');
console.log('✌️ Init file down');
};
+17 -41
View File
@@ -2,23 +2,17 @@ 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',
)}`;
let tokenCommand = `tsx ${join(config.rootPath, 'back/token.ts')}`;
const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) {
@@ -26,50 +20,32 @@ export default async () => {
}
const cron = {
id: NaN,
name: t('生成token'),
name: '生成token',
command: tokenCommand,
runOrigin: 'system',
} as ScheduleTaskType;
await scheduleService.cancelIntervalTask(cron);
scheduleService.createIntervalTask(
cron,
{
days: 28,
},
true,
);
scheduleService.createIntervalTask(cron, {
days: 28,
});
// 运行删除日志任务
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');
}
if (data && data.info && data.info.logRemoveFrequency) {
const rmlogCron = {
id: data.id as number,
name: '删除日志',
command: `ql rmlog ${data.info.logRemoveFrequency}`,
};
await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(rmlogCron, {
days: data.info.logRemoveFrequency,
});
}
// 运行所有订阅
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
subscriptionService.handleTask(sub, !sub.is_disabled, !sub.is_disabled);
}
};
+25 -50
View File
@@ -4,60 +4,35 @@ import config from '../config';
import path from 'path';
const levelMap: Record<string, string> = {
info: 'ℹ️', // info图标
warn: '⚠️', // 警告图标
error: '❌', // 错误图标
debug: '🐛', // debug调试图标
info: '🔵',
warn: '🟡',
error: '🔴',
debug: '🔶'
}
const customFormat = winston.format.combine(
winston.format.splat(),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.align(),
winston.format.printf((i) => `[${levelMap[i.level]}${i.level}] [${[i.timestamp]}]: ${i.message}`),
);
const defaultOptions = {
format: customFormat,
datePattern: "YYYY-MM-DD",
maxSize: "20m",
maxFiles: "7d",
};
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',
level: config.logs.level,
levels: winston.config.npm.levels,
transports: [consoleTransport, fileTransport],
exceptionHandlers: [consoleTransport, fileTransport],
rejectionHandlers: [consoleTransport, fileTransport],
});
LoggerInstance.on('error', (error) => {
console.error('Logger error:', error);
transports: [
new winston.transports.DailyRotateFile({
filename: path.join(config.systemLogPath, '%DATE%.log'),
...defaultOptions,
})
],
});
export default LoggerInstance;
+32
View File
@@ -0,0 +1,32 @@
import { Application } from 'express';
import * as Sentry from '@sentry/node';
import Logger from './logger';
import config from '../config';
import fs from 'fs';
import { parseVersion } from '../config/util';
export default async ({ expressApp }: { expressApp: Application }) => {
const { version } = await parseVersion(config.versionFile);
Sentry.init({
ignoreErrors: [
/SequelizeUniqueConstraintError/i,
/Validation error/i,
/UnauthorizedError/i,
/celebrate request validation failed/i,
],
dsn: 'https://8b5c84cfef3e22541bc84de0ed00497b@o1098464.ingest.sentry.io/6122819',
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.Express({ app: expressApp }),
],
tracesSampleRate: 0.8,
release: version,
});
expressApp.use(Sentry.Handlers.requestHandler());
expressApp.use(Sentry.Handlers.tracingHandler());
Logger.info('✌️ Sentry loaded');
console.log('✌️ Sentry loaded');
};
+23 -1
View File
@@ -4,7 +4,29 @@ import Sock from './sock';
export default async ({ server }: { server: Server }) => {
await Sock({ server });
Logger.info('[boot] Sock loaded');
Logger.info('✌️ Sock loaded');
let exitTime = 0;
let timer: NodeJS.Timeout;
process.on('SIGINT', (singal) => {
Logger.warn(`Server need close, singal ${singal}`);
console.warn(`Server need close, singal ${singal}`);
exitTime++;
if (exitTime >= 3) {
Logger.warn('Forcing server close');
console.warn('Forcing server close');
clearTimeout(timer);
process.exit(1);
}
server.close(() => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
process.exit();
}, 15000);
});
});
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error);
+20 -16
View File
@@ -2,13 +2,12 @@ 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';
import config from '../config/index';
import fs from 'fs/promises';
import { getPlatform, safeJSONParse } from '../config/util';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
@@ -16,22 +15,27 @@ export default async ({ server }: { server: Server }) => {
conn.close('404');
}
const authInfo = await shareStore.getAuthInfo();
const data = await fs.readFile(config.authConfigFile, 'utf8');
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (data) {
const { token = '', tokens = {} } = safeJSONParse(data);
if (headerToken === token || tokens[platform] === headerToken) {
conn.write(JSON.stringify({ type: 'ping', message: 'hanhh' }));
sockService.addClient(conn);
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
return;
} else {
conn.write(JSON.stringify({ type: 'ping', message: 'whyour' }));
}
}
conn.close('404');
-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,
});
},
};
@@ -1,227 +0,0 @@
import { createHash } from 'crypto';
import { DataTypes, Op } from 'sequelize';
import {
APPROVED_ACTION_EXECUTION_STATUSES,
DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
MAX_APPROVED_ACTION_ATTEMPTS,
} from '../runtime/domain/approvedActionDispatchExecution';
import { PROJECT_TABLE } from './0017-project-policy';
import { APPROVED_ACTION_DISPATCH_TABLE } from './0020-approval-requests';
import type { Migration } from './types';
export const APPROVED_ACTION_DISPATCH_EXECUTION_TABLE =
'ApprovedActionDispatchExecutions';
export const APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX =
'approved_action_execution_due_idx';
export const APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX =
'approved_action_execution_project_idx';
export const APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX =
'approved_action_execution_lease_idx';
const columns = [
'dispatch_id',
'project_id',
'status',
'version',
'attempt_count',
'max_attempts',
'eligible_at_ms',
'next_attempt_at_ms',
'lease_owner',
'lease_token',
'lease_expires_at_ms',
'started_at_ms',
'result_mutation_id',
'last_result_code',
'completed_at_ms',
'created_at_ms',
'updated_at_ms',
];
const manifest = {
table: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
columns,
indexes: [
`${APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX}(eligible_at_ms,dispatch_id)`,
`${APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
`${APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
],
constraints: [
'approved_action_execution_status_check',
'approved_action_execution_version_check',
'approved_action_execution_attempt_count_check',
'approved_action_execution_max_attempts_check',
'approved_action_execution_attempt_budget_check',
'approved_action_execution_lease_tuple_check',
'approved_action_execution_created_at_check',
'approved_action_execution_updated_at_check',
],
baselineMaxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
};
export const approvedActionDispatchExecutionManifest = manifest;
export const approvedActionDispatchExecutionMigration: Migration = {
id: '0021-approved-action-dispatch-executions',
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
async up({ queryInterface, transaction }) {
await queryInterface.createTable(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
dispatch_id: {
type: DataTypes.STRING(64),
allowNull: false,
primaryKey: true,
references: { model: APPROVED_ACTION_DISPATCH_TABLE, key: 'id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
project_id: {
type: DataTypes.STRING(128),
allowNull: false,
references: { model: PROJECT_TABLE, key: 'id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
status: { type: DataTypes.STRING(16), allowNull: false },
version: { type: DataTypes.INTEGER, allowNull: false },
attempt_count: { type: DataTypes.INTEGER, allowNull: false },
max_attempts: { type: DataTypes.INTEGER, allowNull: false },
eligible_at_ms: { type: DataTypes.BIGINT, allowNull: true },
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
lease_token: { type: DataTypes.STRING(128), allowNull: true },
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
result_mutation_id: { type: DataTypes.STRING(64), allowNull: true },
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
completed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
},
{ transaction },
);
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: ['status'],
type: 'check',
where: { status: { [Op.in]: APPROVED_ACTION_EXECUTION_STATUSES } },
name: 'approved_action_execution_status_check',
transaction,
},
);
for (const [field, minimum, maximum, name] of [
['version', 0, 2_147_483_647, 'approved_action_execution_version_check'],
[
'attempt_count',
0,
MAX_APPROVED_ACTION_ATTEMPTS,
'approved_action_execution_attempt_count_check',
],
[
'max_attempts',
1,
MAX_APPROVED_ACTION_ATTEMPTS,
'approved_action_execution_max_attempts_check',
],
] as const) {
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: [field],
type: 'check',
where: { [field]: { [Op.between]: [minimum, maximum] } },
name,
transaction,
},
);
}
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: ['attempt_count', 'max_attempts'],
type: 'check',
where: { attempt_count: { [Op.lte]: { [Op.col]: 'max_attempts' } } },
name: 'approved_action_execution_attempt_budget_check',
transaction,
},
);
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: ['lease_owner', 'lease_token', 'lease_expires_at_ms'],
type: 'check',
where: {
[Op.or]: [
{
lease_owner: null,
lease_token: null,
lease_expires_at_ms: null,
},
{
lease_owner: { [Op.not]: null },
lease_token: { [Op.not]: null },
lease_expires_at_ms: { [Op.not]: null },
},
],
},
name: 'approved_action_execution_lease_tuple_check',
transaction,
},
);
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: ['created_at_ms'],
type: 'check',
where: { created_at_ms: { [Op.gte]: 0 } },
name: 'approved_action_execution_created_at_check',
transaction,
},
);
await queryInterface.addConstraint(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
{
fields: ['updated_at_ms', 'created_at_ms'],
type: 'check',
where: { updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } } },
name: 'approved_action_execution_updated_at_check',
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
['eligible_at_ms', 'dispatch_id'],
{
name: APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX,
transaction,
},
);
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
name: APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX,
transaction,
});
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
name: APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX,
transaction,
});
await queryInterface.sequelize.query(
`INSERT INTO "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
(dispatch_id, project_id, status, version, attempt_count, max_attempts,
eligible_at_ms, next_attempt_at_ms, lease_owner, lease_token,
lease_expires_at_ms, started_at_ms, result_mutation_id,
last_result_code, completed_at_ms, created_at_ms, updated_at_ms)
SELECT id, project_id, 'pending', 0, 0, :maxAttempts,
created_at_ms, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
created_at_ms, created_at_ms
FROM "${APPROVED_ACTION_DISPATCH_TABLE}"`,
{
replacements: { maxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS },
transaction,
},
);
},
};
@@ -1,447 +0,0 @@
import { createHash } from 'crypto';
import { DataTypes, Op } from 'sequelize';
import {
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
APPROVED_ACTION_RECOVERY_DECISIONS,
APPROVED_ACTION_RECOVERY_FINDINGS,
APPROVED_ACTION_RECOVERY_SOURCES,
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
MAX_APPROVED_ACTION_RECOVERY_VERSION,
} from '../runtime/domain/approvedActionRecovery';
import { PROJECT_TABLE } from './0017-project-policy';
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from './0021-approved-action-dispatch-executions';
import type { Migration } from './types';
export const APPROVED_ACTION_RECOVERY_CONTROL_TABLE =
'ApprovedActionRecoveryControls';
export const APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE =
'ApprovedActionRecoveryResolutions';
export const APPROVED_ACTION_RECOVERY_DUE_INDEX =
'approved_action_recovery_due_idx';
export const APPROVED_ACTION_RECOVERY_PROJECT_INDEX =
'approved_action_recovery_project_idx';
export const APPROVED_ACTION_RECOVERY_LEASE_INDEX =
'approved_action_recovery_lease_idx';
export const APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX =
'approved_action_recovery_resolution_mutation_uidx';
export const APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX =
'approved_action_recovery_resolution_project_idx';
const controlColumns = [
'dispatch_id',
'project_id',
'execution_version',
'status',
'version',
'next_scan_at_ms',
'lease_owner',
'lease_token',
'lease_expires_at_ms',
'finding_count',
'last_finding_mutation_id',
'last_finding',
'last_result_code',
'last_evidence_digest',
'resolution_mutation_id',
'created_at_ms',
'updated_at_ms',
];
const resolutionColumns = [
'dispatch_id',
'project_id',
'execution_version',
'mutation_id',
'source',
'decision',
'evidence_digest',
'reason_code',
'resolved_by_type',
'resolved_by_id',
'resolved_at_ms',
];
const constraints = [
'approved_action_recovery_status_check',
'approved_action_recovery_execution_version_check',
'approved_action_recovery_version_check',
'approved_action_recovery_finding_count_check',
'approved_action_recovery_lease_tuple_check',
'approved_action_recovery_finding_tuple_check',
'approved_action_recovery_finding_value_check',
'approved_action_recovery_evidence_digest_check',
'approved_action_recovery_resolution_mutation_tuple_check',
'approved_action_recovery_timestamps_check',
'approved_action_recovery_resolution_execution_version_check',
'approved_action_recovery_resolution_source_check',
'approved_action_recovery_resolution_decision_check',
'approved_action_recovery_resolution_actor_tuple_check',
'approved_action_recovery_resolution_source_tuple_check',
'approved_action_recovery_resolution_evidence_digest_check',
'approved_action_recovery_resolution_time_check',
];
const manifest = {
tables: {
ApprovedActionRecoveryControls: controlColumns,
ApprovedActionRecoveryResolutions: resolutionColumns,
},
indexes: [
`${APPROVED_ACTION_RECOVERY_DUE_INDEX}(status,next_scan_at_ms,dispatch_id)`,
`${APPROVED_ACTION_RECOVERY_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
`${APPROVED_ACTION_RECOVERY_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
`${APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX}(mutation_id) UNIQUE`,
`${APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX}(project_id,resolved_at_ms,dispatch_id)`,
],
constraints,
};
export const approvedActionRecoveryManifest = manifest;
export const approvedActionRecoveryMigration: Migration = {
id: '0022-approved-action-recovery',
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
async up({ queryInterface, transaction }) {
await queryInterface.createTable(
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
{
dispatch_id: {
type: DataTypes.STRING(64),
allowNull: false,
primaryKey: true,
references: {
model: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
key: 'dispatch_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
project_id: {
type: DataTypes.STRING(128),
allowNull: false,
references: { model: PROJECT_TABLE, key: 'id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
execution_version: { type: DataTypes.INTEGER, allowNull: false },
status: { type: DataTypes.STRING(24), allowNull: false },
version: { type: DataTypes.INTEGER, allowNull: false },
next_scan_at_ms: { type: DataTypes.BIGINT, allowNull: true },
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
lease_token: { type: DataTypes.STRING(128), allowNull: true },
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
finding_count: { type: DataTypes.INTEGER, allowNull: false },
last_finding_mutation_id: {
type: DataTypes.STRING(64),
allowNull: true,
},
last_finding: { type: DataTypes.STRING(24), allowNull: true },
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
last_evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
resolution_mutation_id: {
type: DataTypes.STRING(64),
allowNull: true,
},
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
},
{ transaction },
);
await queryInterface.createTable(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
{
dispatch_id: {
type: DataTypes.STRING(64),
allowNull: false,
primaryKey: true,
references: {
model: APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
key: 'dispatch_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
project_id: {
type: DataTypes.STRING(128),
allowNull: false,
references: { model: PROJECT_TABLE, key: 'id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
execution_version: { type: DataTypes.INTEGER, allowNull: false },
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
source: { type: DataTypes.STRING(24), allowNull: false },
decision: { type: DataTypes.STRING(24), allowNull: false },
evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
reason_code: { type: DataTypes.STRING(64), allowNull: false },
resolved_by_type: { type: DataTypes.STRING(32), allowNull: true },
resolved_by_id: { type: DataTypes.STRING(255), allowNull: true },
resolved_at_ms: { type: DataTypes.BIGINT, allowNull: false },
},
{ transaction },
);
for (const [table, field, minimum, maximum, name] of [
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'execution_version',
1,
MAX_APPROVED_ACTION_RECOVERY_VERSION,
'approved_action_recovery_execution_version_check',
],
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'version',
0,
MAX_APPROVED_ACTION_RECOVERY_VERSION,
'approved_action_recovery_version_check',
],
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'finding_count',
0,
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
'approved_action_recovery_finding_count_check',
],
[
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
'execution_version',
1,
MAX_APPROVED_ACTION_RECOVERY_VERSION,
'approved_action_recovery_resolution_execution_version_check',
],
] as const) {
await queryInterface.addConstraint(table, {
fields: [field],
type: 'check',
where: { [field]: { [Op.between]: [minimum, maximum] } },
name,
transaction,
});
}
for (const [table, field, values, name] of [
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'status',
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
'approved_action_recovery_status_check',
],
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'last_finding',
APPROVED_ACTION_RECOVERY_FINDINGS,
'approved_action_recovery_finding_value_check',
],
[
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
'source',
APPROVED_ACTION_RECOVERY_SOURCES,
'approved_action_recovery_resolution_source_check',
],
[
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
'decision',
APPROVED_ACTION_RECOVERY_DECISIONS,
'approved_action_recovery_resolution_decision_check',
],
] as const) {
await queryInterface.addConstraint(table, {
fields: [field],
type: 'check',
where: { [field]: { [Op.in]: values } },
name,
transaction,
});
}
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['lease_owner', 'lease_token', 'lease_expires_at_ms'],
type: 'check',
where: {
[Op.or]: [
{ lease_owner: null, lease_token: null, lease_expires_at_ms: null },
{
lease_owner: { [Op.not]: null },
lease_token: { [Op.not]: null },
lease_expires_at_ms: { [Op.not]: null },
},
],
},
name: 'approved_action_recovery_lease_tuple_check',
transaction,
});
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['last_finding_mutation_id', 'last_finding', 'last_result_code'],
type: 'check',
where: {
[Op.or]: [
{
last_finding_mutation_id: null,
last_finding: null,
last_result_code: null,
},
{
last_finding_mutation_id: { [Op.not]: null },
last_finding: { [Op.not]: null },
last_result_code: { [Op.not]: null },
},
],
},
name: 'approved_action_recovery_finding_tuple_check',
transaction,
});
for (const [table, field, name] of [
[
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
'last_evidence_digest',
'approved_action_recovery_evidence_digest_check',
],
[
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
'evidence_digest',
'approved_action_recovery_resolution_evidence_digest_check',
],
] as const) {
await queryInterface.addConstraint(table, {
fields: [field],
type: 'check',
where: {
[Op.or]: [
{ [field]: null },
queryInterface.sequelize.where(
queryInterface.sequelize.fn(
'length',
queryInterface.sequelize.col(field),
),
64,
),
],
},
name,
transaction,
});
}
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['status', 'resolution_mutation_id'],
type: 'check',
where: {
[Op.or]: [
{ status: 'resolved', resolution_mutation_id: { [Op.not]: null } },
{
status: { [Op.not]: 'resolved' },
resolution_mutation_id: null,
},
],
},
name: 'approved_action_recovery_resolution_mutation_tuple_check',
transaction,
});
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['created_at_ms', 'updated_at_ms'],
type: 'check',
where: {
created_at_ms: { [Op.gte]: 0 },
updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } },
},
name: 'approved_action_recovery_timestamps_check',
transaction,
});
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
{
fields: ['resolved_by_type', 'resolved_by_id'],
type: 'check',
where: {
[Op.or]: [
{ resolved_by_type: null, resolved_by_id: null },
{
resolved_by_type: 'user',
resolved_by_id: { [Op.not]: null },
},
],
},
name: 'approved_action_recovery_resolution_actor_tuple_check',
transaction,
},
);
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
{
fields: ['source', 'decision', 'evidence_digest', 'resolved_by_type'],
type: 'check',
where: {
[Op.or]: [
{
source: 'automatic_evidence',
decision: {
[Op.in]: ['confirm_succeeded', 'confirm_failed'],
},
evidence_digest: { [Op.not]: null },
resolved_by_type: null,
},
{ source: 'human', resolved_by_type: 'user' },
],
},
name: 'approved_action_recovery_resolution_source_tuple_check',
transaction,
},
);
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
{
fields: ['resolved_at_ms'],
type: 'check',
where: { resolved_at_ms: { [Op.gte]: 0 } },
name: 'approved_action_recovery_resolution_time_check',
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
['status', 'next_scan_at_ms', 'dispatch_id'],
{ name: APPROVED_ACTION_RECOVERY_DUE_INDEX, transaction },
);
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
name: APPROVED_ACTION_RECOVERY_PROJECT_INDEX,
transaction,
});
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
name: APPROVED_ACTION_RECOVERY_LEASE_INDEX,
transaction,
});
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
['mutation_id'],
{
name: APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX,
unique: true,
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
['project_id', 'resolved_at_ms', 'dispatch_id'],
{
name: APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX,
transaction,
},
);
await queryInterface.sequelize.query(
`INSERT INTO "${APPROVED_ACTION_RECOVERY_CONTROL_TABLE}"
(dispatch_id, project_id, execution_version, status, version,
next_scan_at_ms, lease_owner, lease_token, lease_expires_at_ms,
finding_count, last_finding_mutation_id, last_finding,
last_result_code, last_evidence_digest,
resolution_mutation_id, created_at_ms, updated_at_ms)
SELECT dispatch_id, project_id, version, 'armed', 0,
lease_expires_at_ms, NULL, NULL, NULL,
0, NULL, NULL, NULL, NULL, NULL, started_at_ms, started_at_ms
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
WHERE status = 'executing'`,
{ transaction },
);
},
};
@@ -1,188 +0,0 @@
import { createHash } from 'crypto';
import { DataTypes, Op } from 'sequelize';
import {
APPROVED_RUN_ACTION_TYPE,
APPROVED_RUN_RECEIPT_RESULT_CODE,
APPROVED_RUN_RECEIPT_SCHEMA_VERSION,
} from '../runtime/domain/approvedRunAction';
import { PROJECT_TABLE } from './0017-project-policy';
import {
APPROVAL_REQUEST_TABLE,
APPROVED_ACTION_DISPATCH_TABLE,
} from './0020-approval-requests';
import { RUN_TABLE } from './0002-run-schema';
import type { Migration } from './types';
export const APPROVED_RUN_ACTION_RECEIPT_TABLE = 'ApprovedRunActionReceipts';
export const APPROVED_RUN_ACTION_RECEIPT_PROJECT_INDEX =
'approved_run_receipt_project_idx';
export const APPROVED_RUN_ACTION_RECEIPT_RESOURCE_UNIQUE_INDEX =
'approved_run_receipt_resource_uidx';
const columns = [
'dispatch_id',
'approval_request_id',
'project_id',
'schema_version',
'action_type',
'action_digest',
'execution_attempt',
'execution_version',
'started_at_ms',
'idempotency_key',
'outcome',
'result_code',
'resource_type',
'resource_id',
'finished_at_ms',
'evidence_digest',
'created_at_ms',
];
const manifest = {
table: APPROVED_RUN_ACTION_RECEIPT_TABLE,
columns,
indexes: [
`${APPROVED_RUN_ACTION_RECEIPT_PROJECT_INDEX}(project_id,created_at_ms,dispatch_id)`,
`${APPROVED_RUN_ACTION_RECEIPT_RESOURCE_UNIQUE_INDEX}(resource_type,resource_id)`,
],
constraints: [
'approved_run_receipt_schema_version_check',
'approved_run_receipt_action_type_check',
'approved_run_receipt_execution_attempt_check',
'approved_run_receipt_execution_version_check',
'approved_run_receipt_idempotency_check',
'approved_run_receipt_outcome_check',
'approved_run_receipt_result_code_check',
'approved_run_receipt_resource_type_check',
'approved_run_receipt_timestamps_check',
],
};
export const approvedRunActionReceiptManifest = manifest;
export const approvedRunActionReceiptMigration: Migration = {
id: '0023-approved-run-action-receipts',
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
async up({ queryInterface, transaction }) {
await queryInterface.createTable(
APPROVED_RUN_ACTION_RECEIPT_TABLE,
{
dispatch_id: {
type: DataTypes.STRING(64),
allowNull: false,
primaryKey: true,
references: { model: APPROVED_ACTION_DISPATCH_TABLE, key: 'id' },
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
approval_request_id: {
type: DataTypes.STRING(64),
allowNull: false,
references: { model: APPROVAL_REQUEST_TABLE, key: 'id' },
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
project_id: {
type: DataTypes.STRING(128),
allowNull: false,
references: { model: PROJECT_TABLE, key: 'id' },
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
schema_version: { type: DataTypes.INTEGER, allowNull: false },
action_type: { type: DataTypes.STRING(64), allowNull: false },
action_digest: { type: DataTypes.STRING(64), allowNull: false },
execution_attempt: { type: DataTypes.INTEGER, allowNull: false },
execution_version: { type: DataTypes.INTEGER, allowNull: false },
started_at_ms: { type: DataTypes.BIGINT, allowNull: false },
idempotency_key: { type: DataTypes.STRING(64), allowNull: false },
outcome: { type: DataTypes.STRING(16), allowNull: false },
result_code: { type: DataTypes.STRING(64), allowNull: false },
resource_type: { type: DataTypes.STRING(16), allowNull: false },
resource_id: {
type: DataTypes.STRING(64),
allowNull: false,
references: { model: RUN_TABLE, key: 'id' },
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
finished_at_ms: { type: DataTypes.BIGINT, allowNull: false },
evidence_digest: { type: DataTypes.STRING(64), allowNull: false },
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
},
{ transaction },
);
for (const [field, value, name] of [
[
'schema_version',
APPROVED_RUN_RECEIPT_SCHEMA_VERSION,
'approved_run_receipt_schema_version_check',
],
[
'action_type',
APPROVED_RUN_ACTION_TYPE,
'approved_run_receipt_action_type_check',
],
['outcome', 'succeeded', 'approved_run_receipt_outcome_check'],
[
'result_code',
APPROVED_RUN_RECEIPT_RESULT_CODE,
'approved_run_receipt_result_code_check',
],
['resource_type', 'run', 'approved_run_receipt_resource_type_check'],
] as const) {
await queryInterface.addConstraint(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: [field],
type: 'check',
where: { [field]: value },
name,
transaction,
});
}
for (const [field, maximum, name] of [
['execution_attempt', 16, 'approved_run_receipt_execution_attempt_check'],
[
'execution_version',
2_147_483_647,
'approved_run_receipt_execution_version_check',
],
] as const) {
await queryInterface.addConstraint(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: [field],
type: 'check',
where: { [field]: { [Op.between]: [1, maximum] } },
name,
transaction,
});
}
await queryInterface.addConstraint(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: ['dispatch_id', 'idempotency_key'],
type: 'check',
where: { idempotency_key: { [Op.eq]: { [Op.col]: 'dispatch_id' } } },
name: 'approved_run_receipt_idempotency_check',
transaction,
});
await queryInterface.addConstraint(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: ['started_at_ms', 'finished_at_ms', 'created_at_ms'],
type: 'check',
where: {
finished_at_ms: { [Op.gte]: { [Op.col]: 'started_at_ms' } },
created_at_ms: { [Op.eq]: { [Op.col]: 'finished_at_ms' } },
},
name: 'approved_run_receipt_timestamps_check',
transaction,
});
await queryInterface.addIndex(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: ['project_id', 'created_at_ms', 'dispatch_id'],
name: APPROVED_RUN_ACTION_RECEIPT_PROJECT_INDEX,
transaction,
});
await queryInterface.addIndex(APPROVED_RUN_ACTION_RECEIPT_TABLE, {
fields: ['resource_type', 'resource_id'],
unique: true,
name: APPROVED_RUN_ACTION_RECEIPT_RESOURCE_UNIQUE_INDEX,
transaction,
});
},
};
@@ -1,166 +0,0 @@
import { createHash } from 'crypto';
import { DataTypes, Op } from 'sequelize';
import {
APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES,
MAX_APPROVED_ACTION_RECOVERY_AUTH_AGE_MS,
} from '../runtime/domain/approvedActionRecoveryAuthorization';
import { PROJECT_TABLE } from './0017-project-policy';
import { APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE } from './0022-approved-action-recovery';
import type { Migration } from './types';
export const APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE =
'ApprovedActionRecoveryAuthorizationFacts';
export const APPROVED_ACTION_RECOVERY_AUTHORIZATION_MUTATION_INDEX =
'approved_action_recovery_authorization_mutation_uidx';
export const APPROVED_ACTION_RECOVERY_AUTHORIZATION_PROJECT_INDEX =
'approved_action_recovery_authorization_project_idx';
export const APPROVED_ACTION_RECOVERY_AUTHORIZATION_AUTH_INDEX =
'approved_action_recovery_authorization_auth_idx';
const columns = [
'dispatch_id',
'project_id',
'mutation_id',
'resolved_by_id',
'authentication_id',
'assurance',
'authenticated_at_ms',
'project_version',
'binding_version',
'authorized_at_ms',
'fact_digest',
];
const manifest = {
table: APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
columns,
indexes: [
`${APPROVED_ACTION_RECOVERY_AUTHORIZATION_MUTATION_INDEX}(mutation_id) UNIQUE`,
`${APPROVED_ACTION_RECOVERY_AUTHORIZATION_PROJECT_INDEX}(project_id,authorized_at_ms,dispatch_id)`,
`${APPROVED_ACTION_RECOVERY_AUTHORIZATION_AUTH_INDEX}(authentication_id,authorized_at_ms,dispatch_id)`,
],
constraints: [
'approved_action_recovery_authorization_assurance_check',
'approved_action_recovery_authorization_project_version_check',
'approved_action_recovery_authorization_binding_version_check',
'approved_action_recovery_authorization_recency_check',
],
maxAuthenticationAgeMs: MAX_APPROVED_ACTION_RECOVERY_AUTH_AGE_MS,
};
export const approvedActionRecoveryAuthorizationManifest = manifest;
export const approvedActionRecoveryAuthorizationMigration: Migration = {
id: '0024-approved-action-recovery-authorization',
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
async up({ queryInterface, transaction }) {
await queryInterface.createTable(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
{
dispatch_id: {
type: DataTypes.STRING(64),
allowNull: false,
primaryKey: true,
references: {
model: APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
key: 'dispatch_id',
},
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
project_id: {
type: DataTypes.STRING(128),
allowNull: false,
references: { model: PROJECT_TABLE, key: 'id' },
onDelete: 'RESTRICT',
onUpdate: 'CASCADE',
},
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
resolved_by_id: { type: DataTypes.STRING(255), allowNull: false },
authentication_id: { type: DataTypes.STRING(128), allowNull: false },
assurance: { type: DataTypes.STRING(32), allowNull: false },
authenticated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
project_version: { type: DataTypes.INTEGER, allowNull: false },
binding_version: { type: DataTypes.INTEGER, allowNull: false },
authorized_at_ms: { type: DataTypes.BIGINT, allowNull: false },
fact_digest: { type: DataTypes.STRING(64), allowNull: false },
},
{ transaction },
);
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
{
fields: ['assurance'],
type: 'check',
where: {
assurance: { [Op.in]: APPROVED_ACTION_RECOVERY_STRONG_ASSURANCES },
},
name: 'approved_action_recovery_authorization_assurance_check',
transaction,
},
);
for (const [field, name] of [
[
'project_version',
'approved_action_recovery_authorization_project_version_check',
],
[
'binding_version',
'approved_action_recovery_authorization_binding_version_check',
],
] as const) {
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
{
fields: [field],
type: 'check',
where: { [field]: { [Op.between]: [1, 2_147_483_647] } },
name,
transaction,
},
);
}
await queryInterface.addConstraint(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
{
fields: ['authenticated_at_ms', 'authorized_at_ms'],
type: 'check',
where: {
authorized_at_ms: {
[Op.gte]: { [Op.col]: 'authenticated_at_ms' },
[Op.lte]: queryInterface.sequelize.literal(
`authenticated_at_ms + ${MAX_APPROVED_ACTION_RECOVERY_AUTH_AGE_MS}`,
),
},
},
name: 'approved_action_recovery_authorization_recency_check',
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
['mutation_id'],
{
name: APPROVED_ACTION_RECOVERY_AUTHORIZATION_MUTATION_INDEX,
unique: true,
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
['project_id', 'authorized_at_ms', 'dispatch_id'],
{
name: APPROVED_ACTION_RECOVERY_AUTHORIZATION_PROJECT_INDEX,
transaction,
},
);
await queryInterface.addIndex(
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
['authentication_id', 'authorized_at_ms', 'dispatch_id'],
{
name: APPROVED_ACTION_RECOVERY_AUTHORIZATION_AUTH_INDEX,
transaction,
},
);
},
};
@@ -1,262 +0,0 @@
import {
MigrationStreamHistoryCorruptionError,
type MigrationStreamRecord,
type MigrationStreamStore,
type MigrationStreamTransaction,
} from '../core/migrationStream';
import type {
PostgresClient as PostgresMigrationClient,
PostgresPool as PostgresMigrationPool,
PostgresQueryable as PostgresMigrationQueryable,
PostgresQueryResult as PostgresMigrationQueryResult,
} from '@qinglong/runtime-core';
export type {
PostgresClient as PostgresMigrationClient,
PostgresPool as PostgresMigrationPool,
PostgresQueryable as PostgresMigrationQueryable,
PostgresQueryResult as PostgresMigrationQueryResult,
} from '@qinglong/runtime-core';
export const POSTGRESQL_MAIN_MIGRATION_STREAM_ID = 'postgresql-main';
export const POSTGRESQL_MIGRATION_SCHEMA = 'ql3';
export const POSTGRESQL_MIGRATION_HISTORY_TABLE = 'schema_migrations';
const POSTGRESQL_MIGRATION_LOCK_KEY = [0x514c, 0x0300] as const;
const POSTGRESQL_MIGRATION_STATEMENT_TIMEOUT_MS = 15_000;
const POSTGRESQL_MIGRATION_LOCK_TIMEOUT_MS = 5_000;
const POSTGRESQL_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS = 15_000;
export type PostgresMigrationContext = PostgresMigrationQueryable;
export class PostgresMigrationLeaderUnavailableError extends Error {
constructor() {
super('PostgreSQL migration leader lock is unavailable');
this.name = 'PostgresMigrationLeaderUnavailableError';
}
}
interface HistoryRow extends Record<string, unknown> {
streamId: unknown;
dialect: unknown;
migrationId: unknown;
checksum: unknown;
appliedAtMs: unknown;
}
interface AdvisoryLockRow extends Record<string, unknown> {
acquired: unknown;
}
const CREATE_HISTORY_SQL = `
CREATE TABLE IF NOT EXISTS "${POSTGRESQL_MIGRATION_SCHEMA}"."${POSTGRESQL_MIGRATION_HISTORY_TABLE}" (
migration_id varchar(128) PRIMARY KEY,
stream_id varchar(64) NOT NULL,
dialect varchar(16) NOT NULL
CONSTRAINT ql3_schema_migrations_dialect_check
CHECK (dialect = 'postgresql'),
checksum char(64) NOT NULL
CONSTRAINT ql3_schema_migrations_checksum_check
CHECK (checksum ~ '^[0-9a-f]{64}$'),
applied_at_ms bigint NOT NULL
CONSTRAINT ql3_schema_migrations_applied_at_check
CHECK (applied_at_ms >= 0)
)`.trim();
const SELECT_HISTORY_SQL = `
SELECT
stream_id AS "streamId",
dialect,
migration_id AS "migrationId",
checksum,
applied_at_ms AS "appliedAtMs"
FROM "${POSTGRESQL_MIGRATION_SCHEMA}"."${POSTGRESQL_MIGRATION_HISTORY_TABLE}"
WHERE migration_id = $1
`.trim();
const SELECT_ALL_HISTORY_SQL = `
SELECT
stream_id AS "streamId",
dialect,
migration_id AS "migrationId",
checksum,
applied_at_ms AS "appliedAtMs"
FROM "${POSTGRESQL_MIGRATION_SCHEMA}"."${POSTGRESQL_MIGRATION_HISTORY_TABLE}"
ORDER BY applied_at_ms, migration_id
`.trim();
const INSERT_HISTORY_SQL = `
INSERT INTO "${POSTGRESQL_MIGRATION_SCHEMA}"."${POSTGRESQL_MIGRATION_HISTORY_TABLE}" (
migration_id,
stream_id,
dialect,
checksum,
applied_at_ms
)
VALUES ($1, $2, $3, $4, $5)
`.trim();
function parseAppliedAtMs(value: unknown, migrationId: string): number {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
return value;
}
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) return parsed;
}
throw new MigrationStreamHistoryCorruptionError(migrationId);
}
async function findHistoryRecord(
queryable: PostgresMigrationQueryable,
migrationId: string,
): Promise<MigrationStreamRecord | null> {
const result = await queryable.query<HistoryRow>(SELECT_HISTORY_SQL, [
migrationId,
]);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) {
throw new MigrationStreamHistoryCorruptionError(migrationId);
}
const row = result.rows[0];
if (!row) {
throw new MigrationStreamHistoryCorruptionError(migrationId);
}
return {
streamId: row.streamId as string,
dialect: row.dialect as 'postgresql',
migrationId: row.migrationId as string,
checksum: row.checksum as string,
appliedAtMs: parseAppliedAtMs(row.appliedAtMs, migrationId),
};
}
export async function readPostgresMigrationHistory(
queryable: PostgresMigrationQueryable,
): Promise<readonly MigrationStreamRecord[]> {
const result = await queryable.query<HistoryRow>(SELECT_ALL_HISTORY_SQL);
return result.rows.map((row) => ({
streamId: row.streamId as string,
dialect: row.dialect as 'postgresql',
migrationId: row.migrationId as string,
checksum: row.checksum as string,
appliedAtMs: parseAppliedAtMs(
row.appliedAtMs,
typeof row.migrationId === 'string' ? row.migrationId : 'unknown',
),
}));
}
async function configureTransaction(
client: PostgresMigrationClient,
): Promise<void> {
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
`${POSTGRESQL_MIGRATION_STATEMENT_TIMEOUT_MS}ms`,
]);
await client.query(`SELECT set_config('lock_timeout', $1, true)`, [
`${POSTGRESQL_MIGRATION_LOCK_TIMEOUT_MS}ms`,
]);
await client.query(
`SELECT set_config('idle_in_transaction_session_timeout', $1, true)`,
[`${POSTGRESQL_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS}ms`],
);
}
async function acquireLeaderLock(
client: PostgresMigrationClient,
): Promise<void> {
const result = await client.query<AdvisoryLockRow>(
'SELECT pg_try_advisory_xact_lock($1, $2) AS acquired',
POSTGRESQL_MIGRATION_LOCK_KEY,
);
if (result.rows.length !== 1 || result.rows[0]?.acquired !== true) {
throw new PostgresMigrationLeaderUnavailableError();
}
}
/**
* PostgreSQL migration history adapter without a runtime dependency on `pg`.
* The cluster-only package owns the concrete Pool binding; edge and standalone
* builds can compile this contract without installing or importing the driver.
*/
export class PostgresMigrationStreamStore
implements MigrationStreamStore<PostgresMigrationContext>
{
constructor(private readonly pool: PostgresMigrationPool) {}
async ensureHistory(): Promise<void> {
await this.withLeaderTransaction(async (client) => {
await client.query(
`CREATE SCHEMA IF NOT EXISTS "${POSTGRESQL_MIGRATION_SCHEMA}"`,
);
await client.query(CREATE_HISTORY_SQL);
});
}
async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
return findHistoryRecord(this.pool, migrationId);
}
async listAll(): Promise<readonly MigrationStreamRecord[]> {
return readPostgresMigrationHistory(this.pool);
}
async transaction<T>(
work: (
transaction: MigrationStreamTransaction<PostgresMigrationContext>,
) => Promise<T>,
): Promise<T> {
return this.withLeaderTransaction(async (client) =>
work({
context: client,
findById: (migrationId) => findHistoryRecord(client, migrationId),
insert: async (record) => {
if (
record.streamId !== POSTGRESQL_MAIN_MIGRATION_STREAM_ID ||
record.dialect !== 'postgresql'
) {
throw new TypeError(
'PostgreSQL migration history record has the wrong stream or dialect',
);
}
await client.query(INSERT_HISTORY_SQL, [
record.migrationId,
record.streamId,
record.dialect,
record.checksum,
record.appliedAtMs,
]);
},
}),
);
}
private async withLeaderTransaction<T>(
work: (client: PostgresMigrationClient) => Promise<T>,
): Promise<T> {
const client = await this.pool.connect();
let began = false;
try {
await client.query('BEGIN');
began = true;
await configureTransaction(client);
await acquireLeaderLock(client);
const result = await work(client);
await client.query('COMMIT');
began = false;
return result;
} catch (error) {
if (began) {
try {
await client.query('ROLLBACK');
} catch {
// Preserve the migration error. A broken connection is discarded by
// the concrete driver when release() runs.
}
}
throw error;
} finally {
client.release();
}
}
}
@@ -1,97 +0,0 @@
import type { ModelStatic, Sequelize, Transaction } from 'sequelize';
import type {
SchemaMigrationInstance,
SchemaMigrationAttributes,
} from '../../data/schemaMigration';
import type {
MigrationStreamRecord,
MigrationStreamStore,
MigrationStreamTransaction,
} from '../core/migrationStream';
import type { MigrationContext } from '../types';
export const SQLITE_MAIN_MIGRATION_STREAM_ID = 'sqlite-main';
function toRecord(instance: SchemaMigrationInstance): MigrationStreamRecord {
return {
streamId: SQLITE_MAIN_MIGRATION_STREAM_ID,
dialect: 'sqlite',
migrationId: instance.id,
checksum: instance.checksum,
appliedAtMs: Number(instance.applied_at),
};
}
/**
* Compatibility adapter for the existing Sequelize-owned SQLite connection.
* It preserves the legacy SchemaMigrations row shape while the generic core
* owns ordering, replay and checksum semantics.
*/
export class SequelizeSqliteMigrationStreamStore
implements MigrationStreamStore<MigrationContext>
{
constructor(
private readonly database: Sequelize,
private readonly migrationModel: ModelStatic<SchemaMigrationInstance>,
) {
if (database.getDialect() !== 'sqlite') {
throw new TypeError(
'SequelizeSqliteMigrationStreamStore requires SQLite',
);
}
}
async ensureHistory(): Promise<void> {
await this.migrationModel.sync();
}
async listAll(): Promise<readonly MigrationStreamRecord[]> {
return (await this.migrationModel.findAll()).map(toRecord);
}
async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
const applied = await this.migrationModel.findByPk(migrationId);
return applied ? toRecord(applied) : null;
}
async transaction<T>(
work: (
transaction: MigrationStreamTransaction<MigrationContext>,
) => Promise<T>,
): Promise<T> {
return this.database.transaction(async (transaction) =>
work(this.createTransaction(transaction)),
);
}
private createTransaction(
transaction: Transaction,
): MigrationStreamTransaction<MigrationContext> {
return {
context: {
queryInterface: this.database.getQueryInterface(),
transaction,
},
findById: async (migrationId) => {
const applied = await this.migrationModel.findByPk(migrationId, {
transaction,
});
return applied ? toRecord(applied) : null;
},
insert: async (record) => {
if (
record.streamId !== SQLITE_MAIN_MIGRATION_STREAM_ID ||
record.dialect !== 'sqlite'
) {
throw new TypeError('SQLite migration record identity is invalid');
}
const attributes: SchemaMigrationAttributes = {
id: record.migrationId,
checksum: record.checksum,
applied_at: record.appliedAtMs,
};
await this.migrationModel.create(attributes, { transaction });
},
};
}
}
-361
View File
@@ -1,361 +0,0 @@
export const MIGRATION_STREAM_DIALECTS = ['sqlite', 'postgresql'] as const;
export type MigrationStreamDialect = (typeof MIGRATION_STREAM_DIALECTS)[number];
export const MIGRATION_ID_SCHEMES = [
'sqlite-numbered',
'postgres-prefixed',
] as const;
export type MigrationIdScheme = (typeof MIGRATION_ID_SCHEMES)[number];
export const MIGRATION_CHECKSUM_SCHEMES = ['sha256', 'legacy-opaque'] as const;
export type MigrationChecksumScheme =
(typeof MIGRATION_CHECKSUM_SCHEMES)[number];
export interface MigrationStreamRecord {
streamId: string;
dialect: MigrationStreamDialect;
migrationId: string;
checksum: string;
appliedAtMs: number;
}
export interface MigrationStreamStep<TContext> {
id: string;
checksum: string;
up(context: TContext): Promise<void>;
}
export interface MigrationStreamTransaction<TContext> {
readonly context: TContext;
findById(migrationId: string): Promise<MigrationStreamRecord | null>;
insert(record: MigrationStreamRecord): Promise<void>;
}
export interface MigrationStreamStore<TContext> {
ensureHistory(): Promise<void>;
listAll(): Promise<readonly MigrationStreamRecord[]>;
findById(migrationId: string): Promise<MigrationStreamRecord | null>;
transaction<T>(
work: (transaction: MigrationStreamTransaction<TContext>) => Promise<T>,
): Promise<T>;
}
export interface MigrationStreamDefinition<TContext> {
id: string;
dialect: MigrationStreamDialect;
migrationIdScheme: MigrationIdScheme;
checksumScheme: MigrationChecksumScheme;
migrations: readonly MigrationStreamStep<TContext>[];
}
export interface RunMigrationStreamOptions<TContext> {
stream: MigrationStreamDefinition<TContext>;
store: MigrationStreamStore<TContext>;
clock?: () => number;
logger?: { info(message: string): unknown };
}
const STREAM_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
const SQLITE_MIGRATION_ID_PATTERN = /^\d{4}-[a-z0-9][a-z0-9-]{0,122}$/;
const POSTGRES_MIGRATION_ID_PATTERN = /^pg-\d{4}-[a-z0-9][a-z0-9-]{0,119}$/;
const CHECKSUM_PATTERN = /^[0-9a-f]{64}$/;
const MAX_LEGACY_CHECKSUM_LENGTH = 255;
export class InvalidMigrationStreamError extends TypeError {
constructor(message: string) {
super(`Migration stream is invalid: ${message}`);
this.name = 'InvalidMigrationStreamError';
}
}
export class MigrationStreamChecksumMismatchError extends Error {
constructor(
readonly migrationId: string,
readonly databaseChecksum: string,
readonly codeChecksum: string,
) {
super(
`Migration checksum mismatch: ${migrationId} ` +
`(database=${databaseChecksum}, code=${codeChecksum})`,
);
this.name = 'MigrationStreamChecksumMismatchError';
}
}
export class MigrationStreamHistoryCorruptionError extends Error {
constructor(readonly migrationId: string) {
super(`Migration history is corrupt: ${migrationId}`);
this.name = 'MigrationStreamHistoryCorruptionError';
}
}
export class MigrationStreamAheadOfCodeError extends Error {
constructor(readonly migrationId: string) {
super(`Migration history is ahead of this code: ${migrationId}`);
this.name = 'MigrationStreamAheadOfCodeError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
keys.length === canonical.length &&
keys.every((key, index) => key === canonical[index])
);
}
function checksumIsValid(
scheme: MigrationChecksumScheme,
value: unknown,
): value is string {
if (typeof value !== 'string') return false;
return scheme === 'sha256'
? CHECKSUM_PATTERN.test(value)
: value.length <= MAX_LEGACY_CHECKSUM_LENGTH;
}
function validateDefinition<TContext>(
stream: MigrationStreamDefinition<TContext>,
): void {
if (!stream || typeof stream !== 'object' || Array.isArray(stream)) {
throw new InvalidMigrationStreamError('definition must be an object');
}
if (
!exactKeys(stream, [
'id',
'dialect',
'migrationIdScheme',
'checksumScheme',
'migrations',
])
) {
throw new InvalidMigrationStreamError('definition shape is invalid');
}
if (!STREAM_ID_PATTERN.test(stream.id)) {
throw new InvalidMigrationStreamError('id is invalid');
}
if (!MIGRATION_STREAM_DIALECTS.includes(stream.dialect)) {
throw new InvalidMigrationStreamError('dialect is invalid');
}
if (!MIGRATION_ID_SCHEMES.includes(stream.migrationIdScheme)) {
throw new InvalidMigrationStreamError('migrationIdScheme is invalid');
}
if (!MIGRATION_CHECKSUM_SCHEMES.includes(stream.checksumScheme)) {
throw new InvalidMigrationStreamError('checksumScheme is invalid');
}
if (
(stream.dialect === 'sqlite' &&
stream.migrationIdScheme !== 'sqlite-numbered') ||
(stream.dialect === 'postgresql' &&
stream.migrationIdScheme !== 'postgres-prefixed')
) {
throw new InvalidMigrationStreamError(
'migrationIdScheme does not match dialect',
);
}
if (!Array.isArray(stream.migrations)) {
throw new InvalidMigrationStreamError('migrations must be an array');
}
const ids = new Set<string>();
for (const migration of stream.migrations) {
if (
!migration ||
typeof migration !== 'object' ||
Array.isArray(migration) ||
!exactKeys(migration, ['id', 'checksum', 'up']) ||
!(
stream.migrationIdScheme === 'sqlite-numbered'
? SQLITE_MIGRATION_ID_PATTERN
: POSTGRES_MIGRATION_ID_PATTERN
).test(migration.id) ||
!checksumIsValid(stream.checksumScheme, migration.checksum) ||
typeof migration.up !== 'function'
) {
throw new InvalidMigrationStreamError('migration is invalid');
}
if (ids.has(migration.id)) {
throw new InvalidMigrationStreamError(
`duplicate migration id: ${migration.id}`,
);
}
ids.add(migration.id);
}
}
function validateRecord(
value: MigrationStreamRecord,
stream: MigrationStreamDefinition<unknown>,
migrationId: string,
): void {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'streamId',
'dialect',
'migrationId',
'checksum',
'appliedAtMs',
]) ||
value.streamId !== stream.id ||
value.dialect !== stream.dialect ||
value.migrationId !== migrationId ||
!checksumIsValid(stream.checksumScheme, value.checksum) ||
!Number.isSafeInteger(value.appliedAtMs) ||
value.appliedAtMs < 0
) {
throw new MigrationStreamHistoryCorruptionError(migrationId);
}
}
function assertChecksum(
record: MigrationStreamRecord,
migration: MigrationStreamStep<unknown>,
): void {
if (record.checksum !== migration.checksum) {
throw new MigrationStreamChecksumMismatchError(
migration.id,
record.checksum,
migration.checksum,
);
}
}
export function auditMigrationStreamHistory<TContext>(
history: readonly MigrationStreamRecord[],
stream: MigrationStreamDefinition<TContext>,
): ReadonlySet<string> {
if (!Array.isArray(history)) {
throw new MigrationStreamHistoryCorruptionError('history');
}
const migrationsById = new Map(
stream.migrations.map((migration, index) => [
migration.id,
{ migration, index },
]),
);
const appliedIds = new Set<string>();
const appliedIndexes = new Set<number>();
for (const record of history) {
const expected = migrationsById.get(record?.migrationId);
if (!expected) {
throw new MigrationStreamAheadOfCodeError(
record?.migrationId ?? 'unknown',
);
}
if (appliedIds.has(record.migrationId)) {
throw new MigrationStreamHistoryCorruptionError(record.migrationId);
}
validateRecord(
record,
stream as MigrationStreamDefinition<unknown>,
expected.migration.id,
);
assertChecksum(record, expected.migration as MigrationStreamStep<unknown>);
appliedIds.add(record.migrationId);
appliedIndexes.add(expected.index);
}
for (let index = 0; index < appliedIndexes.size; index += 1) {
if (!appliedIndexes.has(index)) {
throw new MigrationStreamHistoryCorruptionError(
stream.migrations[index].id,
);
}
}
return appliedIds;
}
/**
* Dialect-neutral migration ordering and history semantics. Concrete stores own
* SQL, leader election and transaction APIs; this core never imports a driver.
*/
export async function runMigrationStream<TContext>(
options: RunMigrationStreamOptions<TContext>,
): Promise<void> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new InvalidMigrationStreamError('options must be an object');
}
const expectedKeys = [
'stream',
'store',
...(options.clock === undefined ? [] : ['clock']),
...(options.logger === undefined ? [] : ['logger']),
];
if (!exactKeys(options, expectedKeys)) {
throw new InvalidMigrationStreamError('options shape is invalid');
}
validateDefinition(options.stream);
if (
!options.store ||
typeof options.store !== 'object' ||
typeof options.store.ensureHistory !== 'function' ||
typeof options.store.listAll !== 'function' ||
typeof options.store.findById !== 'function' ||
typeof options.store.transaction !== 'function'
) {
throw new InvalidMigrationStreamError('store is invalid');
}
const clock = options.clock ?? Date.now;
if (typeof clock !== 'function') {
throw new InvalidMigrationStreamError('clock is invalid');
}
await options.store.ensureHistory();
const appliedAtStart = auditMigrationStreamHistory(
await options.store.listAll(),
options.stream,
);
for (const migration of options.stream.migrations) {
if (appliedAtStart.has(migration.id)) continue;
const applied = await options.store.findById(migration.id);
if (applied) {
validateRecord(
applied,
options.stream as MigrationStreamDefinition<unknown>,
migration.id,
);
assertChecksum(applied, migration as MigrationStreamStep<unknown>);
continue;
}
let appliedNow = false;
await options.store.transaction(async (transaction) => {
const appliedInsideTransaction = await transaction.findById(migration.id);
if (appliedInsideTransaction) {
validateRecord(
appliedInsideTransaction,
options.stream as MigrationStreamDefinition<unknown>,
migration.id,
);
assertChecksum(
appliedInsideTransaction,
migration as MigrationStreamStep<unknown>,
);
return;
}
await migration.up(transaction.context);
const appliedAtMs = clock();
if (!Number.isSafeInteger(appliedAtMs) || appliedAtMs < 0) {
throw new InvalidMigrationStreamError(
'clock must return a non-negative safe integer',
);
}
await transaction.insert({
streamId: options.stream.id,
dialect: options.stream.dialect,
migrationId: migration.id,
checksum: migration.checksum,
appliedAtMs,
});
appliedNow = true;
});
if (appliedNow) {
options.logger?.info(
`[migration:${options.stream.id}] Applied ${migration.id}`,
);
}
}
}
-52
View File
@@ -1,52 +0,0 @@
import { runSchemaMigration } from './0002-run-schema';
import { runningInstanceRunReferenceMigration } from './0003-running-instance-run-reference';
import { runCancellationRequestMigration } from './0004-run-cancellation-request';
import { runCancellationDispatchMigration } from './0005-run-cancellation-dispatch';
import { runAttemptDeadlineMigration } from './0006-run-attempt-deadline';
import { completionReceiptJournalMigration } from './0007-completion-receipt-journal';
import { workerRegistryMigration } from './0008-worker-registry';
import { runDispatchLeaseMigration } from './0009-run-dispatch-lease';
import { runDispatchCandidateMigration } from './0010-run-dispatch-candidates';
import { runRetryPolicyMigration } from './0011-run-retry-policy';
import { taskExecutionRevisionMigration } from './0012-task-execution-revisions';
import { localExecutionContextRecipeMigration } from './0013-local-execution-context-recipes';
import { localSecretEnvelopeMigration } from './0014-local-secret-envelopes';
import { localArtifactRetentionMigration } from './0015-local-artifact-retention';
import { localArtifactMaintenanceCursorMigration } from './0016-local-artifact-maintenance-cursor';
import { projectPolicyMigration } from './0017-project-policy';
import { projectOwnerBootstrapMigration } from './0018-project-owner-bootstrap';
import { identityDirectoryMigration } from './0019-identity-directory';
import { approvalRequestMigration } from './0020-approval-requests';
import { approvedActionDispatchExecutionMigration } from './0021-approved-action-dispatch-executions';
import { approvedActionRecoveryMigration } from './0022-approved-action-recovery';
import { approvedRunActionReceiptMigration } from './0023-approved-run-action-receipts';
import { approvedActionRecoveryAuthorizationMigration } from './0024-approved-action-recovery-authorization';
import { legacyColumnsMigration } from './0001-legacy-columns';
import type { Migration } from './types';
export const migrations: Migration[] = [
legacyColumnsMigration,
runSchemaMigration,
runningInstanceRunReferenceMigration,
runCancellationRequestMigration,
runCancellationDispatchMigration,
runAttemptDeadlineMigration,
completionReceiptJournalMigration,
workerRegistryMigration,
runDispatchLeaseMigration,
runDispatchCandidateMigration,
runRetryPolicyMigration,
taskExecutionRevisionMigration,
localExecutionContextRecipeMigration,
localSecretEnvelopeMigration,
localArtifactRetentionMigration,
localArtifactMaintenanceCursorMigration,
projectPolicyMigration,
projectOwnerBootstrapMigration,
identityDirectoryMigration,
approvalRequestMigration,
approvedActionDispatchExecutionMigration,
approvedActionRecoveryMigration,
approvedRunActionReceiptMigration,
approvedActionRecoveryAuthorizationMigration,
];
-21
View File
@@ -1,21 +0,0 @@
import {
POSTGRESQL_MAIN_MIGRATION_STREAM_ID,
type PostgresMigrationContext,
} from '../adapters/postgresMigrationStreamStore';
import type { MigrationStreamDefinition } from '../core/migrationStream';
import { pg0001SchemaCapabilityMigration } from './pg-0001-schema-capability';
import { pg0002RunCoreMigration } from './pg-0002-run-core';
import { pg0003RunRetryPolicyMigration } from './pg-0003-run-retry-policy';
export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMigrationContext> =
Object.freeze({
id: POSTGRESQL_MAIN_MIGRATION_STREAM_ID,
dialect: 'postgresql',
migrationIdScheme: 'postgres-prefixed',
checksumScheme: 'sha256',
migrations: Object.freeze([
pg0001SchemaCapabilityMigration,
pg0002RunCoreMigration,
pg0003RunRetryPolicyMigration,
]),
});
@@ -1,28 +0,0 @@
import { definePostgresSqlMigration } from './sqlMigration';
export const POSTGRESQL_SCHEMA_CAPABILITY_TABLE = 'schema_capabilities';
export const pg0001SchemaCapabilityMigration = definePostgresSqlMigration({
id: 'pg-0001-schema-capability',
statements: [
`
CREATE TABLE "ql3"."${POSTGRESQL_SCHEMA_CAPABILITY_TABLE}" (
contract_name varchar(64) PRIMARY KEY,
contract_version integer NOT NULL
CONSTRAINT ql3_schema_capabilities_version_check
CHECK (contract_version >= 0),
migration_id varchar(128) NOT NULL,
capabilities jsonb NOT NULL
CONSTRAINT ql3_schema_capabilities_payload_check
CHECK (jsonb_typeof(capabilities) = 'object'),
updated_at_ms bigint NOT NULL
CONSTRAINT ql3_schema_capabilities_updated_at_check
CHECK (updated_at_ms >= 0),
CONSTRAINT ql3_schema_capabilities_migration_fk
FOREIGN KEY (migration_id)
REFERENCES "ql3"."schema_migrations" (migration_id)
DEFERRABLE INITIALLY DEFERRED
)
`.trim(),
],
});
@@ -1,224 +0,0 @@
import { definePostgresSqlMigration } from './sqlMigration';
export const POSTGRESQL_RUN_TABLE = 'runs';
export const POSTGRESQL_RUN_ATTEMPT_TABLE = 'run_attempts';
export const POSTGRESQL_RUN_EVENT_TABLE = 'run_events';
export const pg0002RunCoreMigration = definePostgresSqlMigration({
id: 'pg-0002-run-core',
statements: [
`
CREATE TABLE "ql3"."${POSTGRESQL_RUN_TABLE}" (
id varchar(36) PRIMARY KEY,
project_id varchar(128) NOT NULL,
task_id varchar(255) NOT NULL,
task_revision varchar(128) NOT NULL,
task_name varchar(255),
task_snapshot_ref varchar(512),
parent_run_id varchar(36),
retry_of_run_id varchar(36),
trigger_id varchar(36),
trigger_type varchar(64) NOT NULL,
execution_origin varchar(64) NOT NULL,
execution_owner varchar(16) NOT NULL
CONSTRAINT ql3_runs_execution_owner_check
CHECK (execution_owner = 'runtime'),
triggered_by varchar(255),
request_id varchar(128),
scheduled_for_ms bigint
CONSTRAINT ql3_runs_scheduled_for_check
CHECK (scheduled_for_ms IS NULL OR scheduled_for_ms >= 0),
status varchar(32) NOT NULL
CONSTRAINT ql3_runs_status_check
CHECK (status IN (
'created', 'queued', 'dispatching', 'running', 'waiting_approval',
'retry_wait', 'lost', 'succeeded', 'failed', 'cancelled', 'timed_out'
)),
version integer NOT NULL DEFAULT 0
CONSTRAINT ql3_runs_version_check
CHECK (version >= 0),
event_sequence integer NOT NULL DEFAULT 0
CONSTRAINT ql3_runs_event_sequence_check
CHECK (event_sequence >= 0),
priority integer NOT NULL DEFAULT 0,
idempotency_key varchar(255),
input_ref varchar(512),
output_ref varchar(512),
created_at_ms bigint NOT NULL
CONSTRAINT ql3_runs_created_at_check
CHECK (created_at_ms >= 0),
queued_at_ms bigint
CONSTRAINT ql3_runs_queued_at_check
CHECK (queued_at_ms IS NULL OR queued_at_ms >= 0),
started_at_ms bigint
CONSTRAINT ql3_runs_started_at_check
CHECK (started_at_ms IS NULL OR started_at_ms >= 0),
finished_at_ms bigint
CONSTRAINT ql3_runs_finished_at_check
CHECK (finished_at_ms IS NULL OR finished_at_ms >= 0),
cancel_requested_at_ms bigint
CONSTRAINT ql3_runs_cancel_requested_at_check
CHECK (cancel_requested_at_ms IS NULL OR cancel_requested_at_ms >= 0),
cancel_reason varchar(16)
CONSTRAINT ql3_runs_cancel_reason_check
CHECK (cancel_reason IS NULL OR cancel_reason IN (
'user', 'policy', 'shutdown', 'reconcile', 'timeout'
)),
error_code varchar(128),
error_summary varchar(1024),
CONSTRAINT ql3_runs_parent_fk
FOREIGN KEY (parent_run_id) REFERENCES "ql3"."${POSTGRESQL_RUN_TABLE}" (id),
CONSTRAINT ql3_runs_retry_of_fk
FOREIGN KEY (retry_of_run_id) REFERENCES "ql3"."${POSTGRESQL_RUN_TABLE}" (id)
)
`.trim(),
`
CREATE UNIQUE INDEX ql3_runs_project_idempotency_uidx
ON "ql3"."${POSTGRESQL_RUN_TABLE}" (project_id, idempotency_key)
WHERE idempotency_key IS NOT NULL
`.trim(),
`
CREATE INDEX ql3_runs_project_created_idx
ON "ql3"."${POSTGRESQL_RUN_TABLE}" (project_id, created_at_ms, id)
`.trim(),
`
CREATE INDEX ql3_runs_task_created_idx
ON "ql3"."${POSTGRESQL_RUN_TABLE}" (task_id, created_at_ms, id)
`.trim(),
`
CREATE INDEX ql3_runs_dispatch_candidates_idx
ON "ql3"."${POSTGRESQL_RUN_TABLE}" (priority DESC, queued_at_ms, id)
WHERE execution_owner = 'runtime'
AND status IN ('queued', 'dispatching')
AND cancel_requested_at_ms IS NULL
AND queued_at_ms IS NOT NULL
`.trim(),
`
CREATE TABLE "ql3"."${POSTGRESQL_RUN_ATTEMPT_TABLE}" (
id varchar(36) PRIMARY KEY,
run_id varchar(36) NOT NULL,
step_run_id varchar(36),
attempt integer NOT NULL
CONSTRAINT ql3_run_attempts_attempt_check
CHECK (attempt >= 1),
status varchar(32) NOT NULL
CONSTRAINT ql3_run_attempts_status_check
CHECK (status IN (
'claimed', 'starting', 'running', 'succeeded', 'failed',
'cancelled', 'timed_out', 'lost'
)),
executor_type varchar(64) NOT NULL,
worker_id varchar(128),
executor_handle varchar(2048),
pid integer
CONSTRAINT ql3_run_attempts_pid_check
CHECK (pid IS NULL OR pid >= 1),
log_artifact_id varchar(36),
lease_token varchar(128),
lease_expires_at_ms bigint
CONSTRAINT ql3_run_attempts_lease_expiry_check
CHECK (lease_expires_at_ms IS NULL OR lease_expires_at_ms >= 0),
deadline_at_ms bigint
CONSTRAINT ql3_run_attempts_deadline_check
CHECK (deadline_at_ms IS NULL OR deadline_at_ms >= 0),
callback_token_hash varchar(128),
callback_sequence integer NOT NULL DEFAULT 0
CONSTRAINT ql3_run_attempts_callback_sequence_check
CHECK (callback_sequence >= 0),
created_at_ms bigint NOT NULL
CONSTRAINT ql3_run_attempts_created_at_check
CHECK (created_at_ms >= 0),
started_at_ms bigint
CONSTRAINT ql3_run_attempts_started_at_check
CHECK (started_at_ms IS NULL OR started_at_ms >= 0),
finished_at_ms bigint
CONSTRAINT ql3_run_attempts_finished_at_check
CHECK (finished_at_ms IS NULL OR finished_at_ms >= 0),
exit_code integer,
error_code varchar(128),
error_summary varchar(1024),
CONSTRAINT ql3_run_attempts_run_fk
FOREIGN KEY (run_id) REFERENCES "ql3"."${POSTGRESQL_RUN_TABLE}" (id)
)
`.trim(),
`
CREATE UNIQUE INDEX ql3_run_attempts_run_attempt_uidx
ON "ql3"."${POSTGRESQL_RUN_ATTEMPT_TABLE}" (run_id, attempt)
`.trim(),
`
CREATE INDEX ql3_run_attempts_dispatch_candidates_idx
ON "ql3"."${POSTGRESQL_RUN_ATTEMPT_TABLE}" (status, run_id, created_at_ms, id)
`.trim(),
`
CREATE INDEX ql3_run_attempts_lease_idx
ON "ql3"."${POSTGRESQL_RUN_ATTEMPT_TABLE}" (lease_expires_at_ms, id)
WHERE lease_expires_at_ms IS NOT NULL
`.trim(),
`
CREATE TABLE "ql3"."${POSTGRESQL_RUN_EVENT_TABLE}" (
id varchar(36) PRIMARY KEY,
run_id varchar(36) NOT NULL,
sequence integer NOT NULL
CONSTRAINT ql3_run_events_sequence_check
CHECK (sequence >= 1),
type varchar(128) NOT NULL,
dedupe_key varchar(255),
actor_type varchar(64) NOT NULL
CONSTRAINT ql3_run_events_actor_type_check
CHECK (actor_type IN (
'user', 'api_app', 'trigger', 'agent', 'mcp_client', 'worker',
'executor', 'system', 'legacy_shell', 'scheduler', 'reconciler',
'compatibility'
)),
actor_id varchar(255),
attempt_id varchar(36),
step_run_id varchar(36),
payload jsonb NOT NULL
CONSTRAINT ql3_run_events_payload_check
CHECK (jsonb_typeof(payload) = 'object'),
created_at_ms bigint NOT NULL
CONSTRAINT ql3_run_events_created_at_check
CHECK (created_at_ms >= 0),
CONSTRAINT ql3_run_events_run_fk
FOREIGN KEY (run_id) REFERENCES "ql3"."${POSTGRESQL_RUN_TABLE}" (id),
CONSTRAINT ql3_run_events_attempt_fk
FOREIGN KEY (attempt_id) REFERENCES "ql3"."${POSTGRESQL_RUN_ATTEMPT_TABLE}" (id)
)
`.trim(),
`
CREATE UNIQUE INDEX ql3_run_events_run_sequence_uidx
ON "ql3"."${POSTGRESQL_RUN_EVENT_TABLE}" (run_id, sequence)
`.trim(),
`
CREATE UNIQUE INDEX ql3_run_events_run_dedupe_uidx
ON "ql3"."${POSTGRESQL_RUN_EVENT_TABLE}" (run_id, dedupe_key)
WHERE dedupe_key IS NOT NULL
`.trim(),
`
CREATE INDEX ql3_run_events_run_created_idx
ON "ql3"."${POSTGRESQL_RUN_EVENT_TABLE}" (run_id, created_at_ms, id)
`.trim(),
`
INSERT INTO "ql3"."schema_capabilities" (
contract_name,
contract_version,
migration_id,
capabilities,
updated_at_ms
)
VALUES (
'control-core',
1,
'pg-0002-run-core',
'{"run_core":1}'::jsonb,
floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint
)
ON CONFLICT (contract_name) DO UPDATE
SET contract_version = EXCLUDED.contract_version,
migration_id = EXCLUDED.migration_id,
capabilities = EXCLUDED.capabilities,
updated_at_ms = EXCLUDED.updated_at_ms
WHERE "schema_capabilities".contract_version < EXCLUDED.contract_version
`.trim(),
],
});
@@ -1,78 +0,0 @@
import { definePostgresSqlMigration } from './sqlMigration';
export const POSTGRESQL_RUN_RETRY_POLICY_TABLE = 'run_retry_policies';
export const pg0003RunRetryPolicyMigration = definePostgresSqlMigration({
id: 'pg-0003-run-retry-policy',
statements: [
`
ALTER TABLE "ql3"."runs"
ADD COLUMN legacy_cron_id integer
CONSTRAINT ql3_runs_legacy_cron_id_check
CHECK (legacy_cron_id IS NULL OR legacy_cron_id >= 1)
`.trim(),
`
CREATE TABLE "ql3"."${POSTGRESQL_RUN_RETRY_POLICY_TABLE}" (
run_id varchar(36) PRIMARY KEY,
max_attempts integer NOT NULL
CONSTRAINT ql3_run_retry_policies_max_attempts_check
CHECK (max_attempts BETWEEN 1 AND 16),
retry_on_lost boolean NOT NULL,
safety varchar(16) NOT NULL
CONSTRAINT ql3_run_retry_policies_safety_check
CHECK (safety IN ('unknown', 'idempotent', 'deduplicated')),
backoff_base_ms bigint NOT NULL
CONSTRAINT ql3_run_retry_policies_backoff_base_check
CHECK (backoff_base_ms BETWEEN 0 AND 86400000),
backoff_max_ms bigint NOT NULL
CONSTRAINT ql3_run_retry_policies_backoff_max_check
CHECK (backoff_max_ms BETWEEN backoff_base_ms AND 86400000),
next_attempt_at_ms bigint
CONSTRAINT ql3_run_retry_policies_next_attempt_check
CHECK (next_attempt_at_ms IS NULL OR next_attempt_at_ms >= 0),
version integer NOT NULL DEFAULT 0
CONSTRAINT ql3_run_retry_policies_version_check
CHECK (version >= 0),
created_at_ms bigint NOT NULL
CONSTRAINT ql3_run_retry_policies_created_at_check
CHECK (created_at_ms >= 0),
updated_at_ms bigint NOT NULL
CONSTRAINT ql3_run_retry_policies_updated_at_check
CHECK (updated_at_ms >= created_at_ms),
CONSTRAINT ql3_run_retry_policies_run_fk
FOREIGN KEY (run_id) REFERENCES "ql3"."runs" (id) ON DELETE CASCADE
)
`.trim(),
`
CREATE INDEX ql3_run_retry_policies_due_idx
ON "ql3"."${POSTGRESQL_RUN_RETRY_POLICY_TABLE}" (next_attempt_at_ms, run_id)
WHERE next_attempt_at_ms IS NOT NULL
`.trim(),
`
CREATE INDEX ql3_runs_lost_retry_idx
ON "ql3"."runs" (execution_owner, status, id)
`.trim(),
`
DO $ql3$
BEGIN
UPDATE "ql3"."schema_capabilities"
SET contract_version = 2,
migration_id = 'pg-0003-run-retry-policy',
capabilities = '{"run_core":1,"run_retry_policy":1}'::jsonb,
updated_at_ms = floor(
extract(epoch FROM transaction_timestamp()) * 1000
)::bigint
WHERE contract_name = 'control-core'
AND contract_version = 1
AND migration_id = 'pg-0002-run-core'
AND capabilities = '{"run_core":1}'::jsonb;
IF NOT FOUND THEN
RAISE EXCEPTION 'control-core capability is not at version 1'
USING ERRCODE = 'check_violation';
END IF;
END
$ql3$
`.trim(),
],
});

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