mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c37f426a | ||
|
|
a36a77086a | ||
|
|
cb5dfec2b5 | ||
|
|
286088a32c | ||
|
|
a1104ef47c | ||
|
|
1a152575cc | ||
|
|
ef348b445b | ||
|
|
8305825ffd | ||
|
|
e555fcbb0b | ||
|
|
83e8f6bccd | ||
|
|
5801f735bc | ||
|
|
53e1129a12 | ||
|
|
13c0a4d21b | ||
|
|
126fe8eb67 | ||
|
|
73c41eed22 | ||
|
|
40149b6f17 | ||
|
|
6532775a25 | ||
|
|
6469792546 | ||
|
|
0aa521a288 | ||
|
|
1ac04a8062 | ||
|
|
e435f6e886 | ||
|
|
db88d037db | ||
|
|
a0c09046c6 | ||
|
|
5a9e53c771 | ||
|
|
be877cfddc | ||
|
|
fdeffebba8 | ||
|
|
3c61e933a1 | ||
|
|
cf236f8324 | ||
|
|
7334566d3c | ||
|
|
4cd6c6ad2e | ||
|
|
81a60254a2 | ||
|
|
1503f07c75 | ||
|
|
70636900b2 | ||
|
|
331f4f9138 | ||
|
|
b06cb72334 | ||
|
|
89c6355635 | ||
|
|
68ffa106f8 | ||
|
|
9d5f58ef99 | ||
|
|
ef1c54a620 | ||
|
|
0218404d9f | ||
|
|
24f7d1a46f | ||
|
|
fa8910b3d6 | ||
|
|
2cab112391 | ||
|
|
2ab9c702e6 | ||
|
|
6dd98a8565 | ||
|
|
06c8d8b112 | ||
|
|
a5406bedca | ||
|
|
3d2b98a442 | ||
|
|
93d7ec69dc | ||
|
|
a303e33b4e | ||
|
|
44129ca088 | ||
|
|
def4917447 | ||
|
|
bc0f35e3f7 | ||
|
|
f168efcdae | ||
|
|
bf63425805 | ||
|
|
a8310e7d51 | ||
|
|
a67d44cde5 | ||
|
|
6c487018c2 | ||
|
|
801a71d740 | ||
|
|
f051135fc4 | ||
|
|
2d6c2206b3 | ||
|
|
6acb665369 | ||
|
|
d9833c17d5 | ||
|
|
a60ec5587a | ||
|
|
4eb27427f8 | ||
|
|
041a437453 | ||
|
|
841740f19a | ||
|
|
a94e665054 | ||
|
|
d62d8f3025 | ||
|
|
4df52094f2 | ||
|
|
be2580d0e8 | ||
|
|
5f6049d80a | ||
|
|
0e975d1d6d | ||
|
|
134cd8337c | ||
|
|
88200f2805 | ||
|
|
decdab093f | ||
|
|
dfa73af104 | ||
|
|
f71fd5ebd6 | ||
|
|
afe9b5702b | ||
|
|
02e02491c4 | ||
|
|
276dfc2382 | ||
|
|
31f78e4d1f | ||
|
|
83e4490b57 | ||
|
|
2ac4f07fca | ||
|
|
6618240c61 | ||
|
|
0bb41ebe96 | ||
|
|
d67f33df39 | ||
|
|
24d539fc05 | ||
|
|
0af546cd1d | ||
|
|
0d33d2321a | ||
|
|
79964f149c | ||
|
|
bcb2471768 | ||
|
|
fcf0ee619b | ||
|
|
f3ec352066 | ||
|
|
023d0f1cc4 | ||
|
|
f526d3f972 | ||
|
|
e28f746294 |
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitnexus
|
||||
.claude
|
||||
.codex
|
||||
.env*
|
||||
node_modules
|
||||
data
|
||||
audit
|
||||
docs
|
||||
test
|
||||
src/.umi*
|
||||
**/__pycache__
|
||||
**/*.log
|
||||
@@ -6,6 +6,13 @@ BACK_PORT=5700
|
||||
# BIND_HOST=0.0.0.0
|
||||
# BIND_HOST_GRPC=0.0.0.0
|
||||
|
||||
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
|
||||
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
|
||||
# QL_TRUST_PROXY=1
|
||||
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
|
||||
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
|
||||
# QL_TRUST_PROXY=loopback
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
JWT_SECRET=
|
||||
@@ -14,3 +21,6 @@ JWT_EXPIRES_IN=
|
||||
QINIU_AK=
|
||||
QINIU_SK=
|
||||
QINIU_SCOPE=
|
||||
|
||||
# Python 虚拟环境路径,留空则使用系统 Python
|
||||
# PYTHON_VENV_DIR=./.venv
|
||||
|
||||
@@ -2,20 +2,47 @@ name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
paths:
|
||||
- "**"
|
||||
- "!*.md"
|
||||
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||
- "!cli/**"
|
||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
||||
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
static_sync_only:
|
||||
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||
type: boolean
|
||||
default: false
|
||||
static_ref:
|
||||
description: Static branch or version name, for example v2.22.0
|
||||
type: string
|
||||
default: ''
|
||||
static_ref_type:
|
||||
description: Synchronize a branch, or a version branch and Git tag
|
||||
type: choice
|
||||
options: [branch, tag]
|
||||
default: branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
code_gitlab:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -32,37 +59,9 @@ jobs:
|
||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
|
||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
||||
|
||||
code_gitee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Push to Gitee
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
|
||||
|
||||
if git push --force --all gitee 2>&1; then
|
||||
echo "::notice::Gitee push --all succeeded"
|
||||
else
|
||||
echo "::warning::Push failed, trying to create repo via API..."
|
||||
curl -sS --connect-timeout 30 --max-time 60 \
|
||||
-X POST "https://gitee.com/api/v5/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"qinglong","private":"false"}' \
|
||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
|
||||
fi
|
||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
||||
|
||||
build-static:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -72,6 +71,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -80,82 +80,150 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
pnpm build:info
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
- name: copy to static repo
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
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}
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
- name: publish verified upgrade download sizes
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: static-gitlab-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Push qinglong-static to GitLab
|
||||
env:
|
||||
STATIC_BRANCH: ${{ github.ref_name }}
|
||||
STATIC_REF_TYPE: ${{ github.ref_type }}
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set +e
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||
cd static-mirror
|
||||
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
|
||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
|
||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
|
||||
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||
|
||||
static_gitee:
|
||||
verify-gitee-mirrors:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repository: [qinglong, qinglong-static]
|
||||
steps:
|
||||
- name: Push qinglong-static to Gitee
|
||||
- uses: actions/checkout@v6
|
||||
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
|
||||
# Verification runs alongside Docker builds and needs no Gitee credentials.
|
||||
- name: Wait for Gitee Pull mirror
|
||||
if: ${{ matrix.repository == 'qinglong' }}
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
|
||||
MIRROR_REPOSITORY: ${{ matrix.repository }}
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false'
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||
cd static-mirror
|
||||
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
||||
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
- name: Notify and wait for Gitee static mirror
|
||||
if: ${{ matrix.repository == 'qinglong-static' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
if git push --force --all gitee 2>&1; then
|
||||
echo "::notice::Gitee static push succeeded"
|
||||
else
|
||||
echo "::warning::Push failed, trying to create repo via API..."
|
||||
curl -sS --connect-timeout 30 --max-time 60 \
|
||||
-X POST "https://gitee.com/api/v5/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"qinglong-static","private":"false"}' \
|
||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
||||
fi
|
||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
||||
sync-existing-static:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mirror: [gitlab]
|
||||
concurrency:
|
||||
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Synchronize existing static snapshot
|
||||
env:
|
||||
STATIC_REF: ${{ inputs.static_ref }}
|
||||
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
MIRROR_HOST: ${{ matrix.mirror }}.com
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
|
||||
|
||||
verify-existing-static-gitee:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Wait for existing Gitee static snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ inputs.static_ref }}
|
||||
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
build-alpine:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -205,10 +273,11 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Alpine)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -216,6 +285,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
||||
@@ -224,17 +295,24 @@ jobs:
|
||||
build-debian:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -283,10 +361,11 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Debian)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -295,6 +374,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||
@@ -309,11 +390,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -351,7 +437,7 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -359,6 +445,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.310
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: |
|
||||
whyour/qinglong:python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||
@@ -374,11 +462,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -416,7 +509,7 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
@@ -425,6 +518,8 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian310
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: |
|
||||
whyour/qinglong:debian-python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
|
||||
@@ -432,9 +527,12 @@ jobs:
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||
|
||||
publish:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: [build-alpine, build-debian]
|
||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
@@ -443,6 +541,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
@@ -451,7 +550,115 @@ jobs:
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: publish npm package
|
||||
- name: Set up Node for trusted publishing
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Publish npm package with OIDC
|
||||
shell: bash
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
npm publish
|
||||
set -euo pipefail
|
||||
name=$(node -p 'require("./package.json").name')
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||
npm publish --access public --registry=https://registry.npmjs.org
|
||||
else
|
||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-upgrade-bundles:
|
||||
needs: [build-alpine, build-debian]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux/amd64, libc: musl, name: x64-musl }
|
||||
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
|
||||
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
|
||||
- { platform: linux/arm64, libc: musl, name: arm64-musl }
|
||||
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
|
||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||
- { platform: linux/arm/v6, libc: glibc, name: armv6-glibc }
|
||||
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build and probe the immutable platform package
|
||||
env:
|
||||
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="$ALPINE_DIGEST"
|
||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||
test -n "$digest"
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
|
||||
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
|
||||
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
|
||||
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
|
||||
mkdir -p output/validator
|
||||
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
|
||||
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
|
||||
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
|
||||
else
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
fi
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||
path: output/upgrade/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish-upgrade-bundles:
|
||||
needs: build-upgrade-bundles
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: upgrade-channel-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- run: pnpm install --prod --frozen-lockfile
|
||||
- run: npm --prefix cli ci
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: upgrade-*-${{ github.sha }}
|
||||
path: output/upgrade
|
||||
merge-multiple: true
|
||||
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||
- name: Publish complete packages before advancing the channel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
name: CLI package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'shell/**'
|
||||
- 'back/api/**'
|
||||
- 'back/loaders/deps.ts'
|
||||
- 'package.json'
|
||||
- '.github/workflows/cli-package.yml'
|
||||
push:
|
||||
branches: [develop, master]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'shell/**'
|
||||
- 'back/api/**'
|
||||
- 'back/loaders/deps.ts'
|
||||
- 'package.json'
|
||||
- '.github/workflows/cli-package.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish the verified CLI package (master stable / develop prerelease)
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease_version:
|
||||
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Keep validation runs separate from releases; never interrupt a running release.
|
||||
# Master push releases share the same group as manually requested releases.
|
||||
group: >-
|
||||
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
|
||||
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
|
||||
cancel-in-progress: >-
|
||||
${{ github.ref != 'refs/heads/master' &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['22.12.0', '24']
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: cli/package-lock.json
|
||||
- name: Install test interpreters and tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
|
||||
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
|
||||
- name: Set requested prerelease version before verification
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
|
||||
working-directory: cli
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_REF" != refs/heads/develop ||
|
||||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
|
||||
exit 1
|
||||
fi
|
||||
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
|
||||
- run: npm ci --prefix cli --no-audit --no-fund
|
||||
- run: npm run check:cli
|
||||
- run: npm run test:cli
|
||||
- name: Verify offline package installation
|
||||
env:
|
||||
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
|
||||
run: node cli/scripts/verify-package.cjs
|
||||
- name: Upload verified npm archive
|
||||
if: matrix.node == '24'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-cli-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/cli-package/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
if: >-
|
||||
github.repository == 'whyour/qinglong' &&
|
||||
((github.ref == 'refs/heads/master' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
|
||||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
|
||||
inputs.publish && inputs.prerelease_version != ''))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
registry-url: https://registry.npmjs.org
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-cli-${{ github.sha }}
|
||||
path: cli-package
|
||||
- name: Publish verified npm archive
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
archives=(cli-package/*.tgz)
|
||||
if [ "${#archives[@]}" -ne 1 ]; then
|
||||
echo '::error::Expected exactly one verified CLI archive.'
|
||||
exit 1
|
||||
fi
|
||||
archive="${archives[0]}"
|
||||
metadata=$(tar -xOf "$archive" package/package.json)
|
||||
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
|
||||
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
|
||||
if [[ "$version" != "$PRERELEASE_VERSION" ||
|
||||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Archive version does not match the requested prerelease.'
|
||||
exit 1
|
||||
fi
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
tag=latest
|
||||
else
|
||||
echo '::error::Refusing to publish this branch/version combination.'
|
||||
exit 1
|
||||
fi
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
|
||||
jq -e --arg version "$version" '. == $version' version.json > /dev/null
|
||||
if [[ "$tag" != latest ]]; then
|
||||
echo '::error::Prerelease version already exists; choose a new version to test this build.'
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
|
||||
exit 0
|
||||
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
||||
echo '::error::Could not check the published CLI version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
|
||||
@@ -1,122 +0,0 @@
|
||||
name: QingLong 3.0 Approval Management Kubernetes live evidence
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-approval-management-live-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approval-management-live:
|
||||
name: Three-node Approval, OIDC, mTLS and CloudNativePG
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
minimum_kib="$((25 * 1024 * 1024))"
|
||||
if (( available_kib < minimum_kib )); then
|
||||
echo "Approval live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
||||
docker system df
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.32.8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
||||
|
||||
- name: Recheck static Approval and evidence contracts
|
||||
run: |
|
||||
pnpm audit:cluster-deployment:ql3
|
||||
node --test \
|
||||
test/back/ql3ApprovalManagementKubernetesLiveContract.test.cjs \
|
||||
test/back/ql3ApprovalManagementKubernetesLiveAudit.test.cjs
|
||||
pnpm --filter @qinglong/runtime-core build
|
||||
|
||||
- name: Preload the digest-bound K3s distribution
|
||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
||||
|
||||
- name: Prove Approval over three nodes, mTLS, OIDC and CloudNativePG
|
||||
env:
|
||||
QL3_APPROVAL_MANAGEMENT_KUBERNETES_LIVE: '1'
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
||||
QL3_APPROVAL_REPORT: ${{ runner.temp }}/ql3-approval/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mkdir -m 0700 "$(dirname "${QL3_APPROVAL_REPORT}")"
|
||||
node scripts/ql3-approval-management-kubernetes-live-contract.cjs \
|
||||
"--report=${QL3_APPROVAL_REPORT}"
|
||||
|
||||
- name: Re-audit private report and isolated cleanup
|
||||
env:
|
||||
QL3_APPROVAL_REPORT: ${{ runner.temp }}/ql3-approval/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(stat -c '%a' "${QL3_APPROVAL_REPORT}")" = '600'
|
||||
pnpm audit:approval-management-kubernetes-live:ql3 \
|
||||
"--report=${QL3_APPROVAL_REPORT}"
|
||||
sha256sum "${QL3_APPROVAL_REPORT}"
|
||||
test -z "$(docker ps -aq --filter name=ql3-approval-live-)"
|
||||
test -z "$(docker network ls -q --filter name=ql3-approval-live-)"
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
diff --unified \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
docker system df
|
||||
|
||||
- name: Upload audited content-free Approval evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-approval-management-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-approval/report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
||||
name: QingLong 3.0 CloudNativePG DR live evidence
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-cloudnativepg-dr-live-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cloudnativepg-dr-live:
|
||||
name: Barman WAL, latest and PITR recovery
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Require enough ephemeral disk for four isolated K3s nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
minimum_kib="$((35 * 1024 * 1024))"
|
||||
if (( available_kib < minimum_kib )); then
|
||||
echo "CloudNativePG DR live gate requires at least 35 GiB free; found ${available_kib} KiB" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
||||
docker system df
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.32.8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Fetch checksum-locked operator manifests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/cert-manager.yaml" \
|
||||
https://github.com/cert-manager/cert-manager/releases/download/v1.20.3/cert-manager.yaml
|
||||
echo "7ee74ba06845213e96d8ceaff3d20dd51e682765c1418eddda4e8780ba082261 ${RUNNER_TEMP}/cert-manager.yaml" | sha256sum --check
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/barman-cloud.yaml" \
|
||||
https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.13.0/manifest.yaml
|
||||
echo "d2e71e7b06822448f1a421f05781846cfdb9cc621e7ef32eef5e20c5133213b0 ${RUNNER_TEMP}/barman-cloud.yaml" | sha256sum --check
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
||||
|
||||
- name: Recheck the static backup and supply-chain contracts
|
||||
run: |
|
||||
pnpm audit:cloudnativepg-backup:ql3
|
||||
pnpm audit:barman-cloud-supply-chain:ql3
|
||||
pnpm audit:cert-manager-selection:ql3
|
||||
node --test \
|
||||
test/back/ql3CloudNativePgBarmanLiveContract.test.cjs \
|
||||
test/back/ql3CloudNativePgDrEvidenceAudit.test.cjs \
|
||||
test/back/ql3CloudNativePgBarmanWorkflow.test.cjs
|
||||
|
||||
- name: Prove TLS object storage, continuous WAL, latest restore and PITR
|
||||
env:
|
||||
QL3_CLOUDNATIVEPG_BARMAN_LIVE: '1'
|
||||
QL3_SOURCE_REVISION: ${{ github.sha }}
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_CERT_MANAGER_MANIFEST_FILE: ${{ runner.temp }}/cert-manager.yaml
|
||||
QL3_BARMAN_MANIFEST_FILE: ${{ runner.temp }}/barman-cloud.yaml
|
||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
||||
QL3_DR_REPORT: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mkdir -m 0700 "$(dirname "${QL3_DR_REPORT}")"
|
||||
pnpm test:cloudnativepg-barman-live:ql3 -- \
|
||||
"--report=${QL3_DR_REPORT}"
|
||||
|
||||
- name: Re-audit the exact private report and isolated cleanup
|
||||
env:
|
||||
QL3_DR_REPORT: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(stat -c '%a' "${QL3_DR_REPORT}")" = '600'
|
||||
pnpm audit:cloudnativepg-dr-evidence:ql3 -- \
|
||||
"--report=${QL3_DR_REPORT}"
|
||||
sha256sum "${QL3_DR_REPORT}"
|
||||
test -z "$(docker ps -aq --filter name=ql3-barman-dr-)"
|
||||
test -z "$(docker network ls -q --filter name=ql3-barman-dr-)"
|
||||
test -z "$(docker ps -aq --filter label=io.qinglong.ql3.live=cloudnativepg-barman-disaster-recovery)"
|
||||
test -z "$(docker network ls -q --filter label=io.qinglong.ql3.live=cloudnativepg-barman-disaster-recovery)"
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
diff --unified \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
docker system df
|
||||
|
||||
- name: Upload the audited content-free DR evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-cloudnativepg-dr-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-cloudnativepg-dr/report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
name: QingLong 3.0 Kubernetes deployment live contract
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
pull_request:
|
||||
branches:
|
||||
- next
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-kubernetes-deployment-live-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
kubernetes-deployment-live:
|
||||
name: Deployment Head and fenced resource retirement
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.34.3
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Preload the digest-reviewed K3s distribution
|
||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
||||
|
||||
- name: Recheck the closed deployment ceremony contract
|
||||
run: node --test test/back/ql3KubernetesDeploymentCeremony.test.cjs
|
||||
|
||||
- name: Prove install, UID/resourceVersion retirement and Head convergence
|
||||
env:
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_DEPLOYMENT_LIVE_REPORT: ${{ runner.temp }}/ql3-kubernetes-deployment/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
install -d -m 0700 "$(dirname "${QL3_DEPLOYMENT_LIVE_REPORT}")"
|
||||
node scripts/ql3-kubernetes-deployment-live-contract.cjs > \
|
||||
"${QL3_DEPLOYMENT_LIVE_REPORT}"
|
||||
node -e \
|
||||
"const fs=require('node:fs');const report=JSON.parse(fs.readFileSync(process.env.QL3_DEPLOYMENT_LIVE_REPORT,'utf8'));if(report.cleanupComplete!==true||report.receiptAuditCompatible!==true||report.retirement?.receiptAuditCompatible!==true||report.retirement?.targetAbsent!==true||report.retirement?.uidResourceVersionDeletePreconditions!==true||report.retirement?.deploymentHeadCas!==true||report.retirement?.unixSocketProxy!==true)process.exit(1)"
|
||||
|
||||
- name: Verify isolated cleanup
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -z "$(docker ps -aq --filter name=ql3-deploy-live-)"
|
||||
test -z "$(docker network ls -q --filter name=ql3-deploy-live-)"
|
||||
|
||||
- name: Upload content-free deployment evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-kubernetes-deployment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-kubernetes-deployment/report.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
@@ -1,124 +0,0 @@
|
||||
name: QingLong 3.0 Run Management Kubernetes live evidence
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-run-management-live-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
run-management-live:
|
||||
name: Three-node Run retry, stop, OIDC, mTLS and CloudNativePG
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
minimum_kib="$((25 * 1024 * 1024))"
|
||||
if (( available_kib < minimum_kib )); then
|
||||
echo "Run management live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
||||
docker system df
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.32.8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.32.8/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
||||
|
||||
- name: Recheck static Run management and evidence contracts
|
||||
run: |
|
||||
pnpm audit:cluster-deployment:ql3
|
||||
node --test \
|
||||
test/back/ql3RunManagementDeployment.test.cjs \
|
||||
test/back/ql3RunManagementKubernetesLiveContract.test.cjs \
|
||||
test/back/ql3RunManagementKubernetesLiveAudit.test.cjs
|
||||
pnpm --filter @qinglong/cluster-admin build
|
||||
pnpm --filter @qinglong/cluster-control build
|
||||
|
||||
- name: Preload the digest-bound K3s distribution
|
||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
||||
|
||||
- name: Prove Run retry and stop over three nodes, mTLS, OIDC and CloudNativePG
|
||||
env:
|
||||
QL3_RUN_MANAGEMENT_KUBERNETES_LIVE: '1'
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
||||
QL3_RUN_MANAGEMENT_REPORT: ${{ runner.temp }}/ql3-run-management/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mkdir -m 0700 "$(dirname "${QL3_RUN_MANAGEMENT_REPORT}")"
|
||||
node scripts/ql3-run-management-kubernetes-live-contract.cjs \
|
||||
"--report=${QL3_RUN_MANAGEMENT_REPORT}"
|
||||
|
||||
- name: Re-audit private report and isolated cleanup
|
||||
env:
|
||||
QL3_RUN_MANAGEMENT_REPORT: ${{ runner.temp }}/ql3-run-management/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(stat -c '%a' "${QL3_RUN_MANAGEMENT_REPORT}")" = '600'
|
||||
pnpm audit:run-management-kubernetes-live:ql3 \
|
||||
"--report=${QL3_RUN_MANAGEMENT_REPORT}"
|
||||
sha256sum "${QL3_RUN_MANAGEMENT_REPORT}"
|
||||
test -z "$(docker ps -aq --filter name=ql3-run-live-)"
|
||||
test -z "$(docker network ls -q --filter name=ql3-run-live-)"
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
diff --unified \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
docker system df
|
||||
|
||||
- name: Upload audited content-free Run management evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-run-management-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-run-management/report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
@@ -1,151 +0,0 @@
|
||||
name: QingLong 3.0 Security Administration Kubernetes live evidence
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
paths:
|
||||
- .github/workflows/ql3-security-administration-live.yml
|
||||
- deploy/containers/ql3-cluster-admin/**
|
||||
- deploy/containers/ql3-cluster-control/**
|
||||
- deploy/kubernetes/ql3-cluster/base/**
|
||||
- deploy/kubernetes/ql3-cluster/operations/cloudnative-pg/**
|
||||
- deploy/kubernetes/ql3-cluster/operations/security-administration/**
|
||||
- package.json
|
||||
- packages/ql3-cluster-admin/**
|
||||
- packages/ql3-cluster-control/**
|
||||
- packages/ql3-cluster-postgres/**
|
||||
- packages/ql3-runtime-core/**
|
||||
- pnpm-lock.yaml
|
||||
- scripts/lib/ql3-k3s-docker-live.cjs
|
||||
- scripts/ql3-security-administration-kubernetes-*.cjs
|
||||
- test/back/ql3SecurityAdministrationKubernetes*.test.cjs
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-security-administration-live-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
security-administration-live:
|
||||
name: Three-node Security Administration, PostgreSQL and PVC custody
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Require enough ephemeral disk for three isolated K3s nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
minimum_kib="$((25 * 1024 * 1024))"
|
||||
if (( available_kib < minimum_kib )); then
|
||||
echo "Security Administration live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
||||
docker system df
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.34.3
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Fetch checksum-locked CloudNativePG operator manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/cloudnative-pg.yaml" \
|
||||
https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml
|
||||
echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check
|
||||
|
||||
- name: Recheck static Security Administration and evidence contracts
|
||||
run: |
|
||||
pnpm --filter @qinglong/runtime-core build
|
||||
pnpm --filter @qinglong/cluster-postgres build
|
||||
pnpm audit:security-administration-kubernetes:ql3
|
||||
node --test \
|
||||
test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs \
|
||||
test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs
|
||||
|
||||
- name: Preload the digest-bound K3s distribution
|
||||
run: docker pull rancher/k3s:v1.34.3-k3s1
|
||||
|
||||
- name: Prepare Linux bridge netfilter for nested K3s
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo modprobe br_netfilter
|
||||
test -e /proc/sys/net/bridge/bridge-nf-call-iptables
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
sudo sysctl -w net.bridge.bridge-nf-call-iptables=1
|
||||
|
||||
- name: Prove Security Administration through K3s, PostgreSQL and PVC
|
||||
env:
|
||||
QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE: '1'
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml
|
||||
QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mkdir -m 0700 "$(dirname "${QL3_SECURITY_ADMINISTRATION_REPORT}")"
|
||||
pnpm test:security-administration-kubernetes-live:ql3 \
|
||||
"--report=${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
||||
|
||||
- name: Re-audit content-free report and isolated cleanup
|
||||
env:
|
||||
QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(stat -c '%a' "${QL3_SECURITY_ADMINISTRATION_REPORT}")" = '600'
|
||||
pnpm audit:security-administration-kubernetes-live:ql3 \
|
||||
"--report=${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
||||
sha256sum "${QL3_SECURITY_ADMINISTRATION_REPORT}"
|
||||
test -z "$(docker ps -aq --filter name=ql3-security-live-)"
|
||||
test -z "$(docker network ls -q --filter name=ql3-security-live-)"
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
diff --unified \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
docker system df
|
||||
|
||||
- name: Upload audited content-free Security Administration evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-security-administration-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-security-administration/report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
@@ -1,122 +0,0 @@
|
||||
name: QingLong 3.0 Worker Kubernetes rollout live evidence
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ql3-worker-kubernetes-rollout-live-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
worker-kubernetes-rollout-live:
|
||||
name: Production Worker, Recreate, mTLS, PVC and Session drain
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Require isolated fixture capacity
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
minimum_kib="$((15 * 1024 * 1024))"
|
||||
if (( available_kib < minimum_kib )); then
|
||||
echo "Worker rollout live gate requires at least 15 GiB free; found ${available_kib} KiB" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before"
|
||||
docker system df
|
||||
|
||||
- name: Install workspace dependencies without lifecycle scripts
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install verified kubectl v1.34.3
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256
|
||||
test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \
|
||||
"$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)"
|
||||
chmod 0755 "${RUNNER_TEMP}/kubectl"
|
||||
|
||||
- name: Recheck Worker runtime and evidence contracts
|
||||
run: |
|
||||
pnpm --filter @qinglong/worker-runtime test
|
||||
node --test test/back/ql3WorkerKubernetesRolloutLiveAudit.test.cjs
|
||||
pnpm audit:worker-deployment:ql3
|
||||
|
||||
- name: Preload digest-bound fixture images
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull \
|
||||
rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c
|
||||
docker tag \
|
||||
rancher/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c \
|
||||
rancher/k3s:v1.34.3-k3s1
|
||||
docker pull \
|
||||
postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296
|
||||
docker tag \
|
||||
postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296 \
|
||||
postgres:18.4-bookworm
|
||||
|
||||
- name: Prove production Worker Kubernetes lifecycle
|
||||
env:
|
||||
QL3_WORKER_KUBERNETES_ROLLOUT_LIVE: '1'
|
||||
QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl
|
||||
QL3_WORKER_KUBERNETES_REPORT: ${{ runner.temp }}/ql3-worker/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mkdir -m 0700 "$(dirname "${QL3_WORKER_KUBERNETES_REPORT}")"
|
||||
pnpm test:worker-kubernetes-rollout-live:ql3 \
|
||||
"--report=${QL3_WORKER_KUBERNETES_REPORT}"
|
||||
|
||||
- name: Re-audit private report and isolated cleanup
|
||||
env:
|
||||
QL3_WORKER_KUBERNETES_REPORT: ${{ runner.temp }}/ql3-worker/report.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(stat -c '%a' "${QL3_WORKER_KUBERNETES_REPORT}")" = '600'
|
||||
pnpm audit:worker-kubernetes-rollout-live:ql3 \
|
||||
"--report=${QL3_WORKER_KUBERNETES_REPORT}"
|
||||
sha256sum "${QL3_WORKER_KUBERNETES_REPORT}"
|
||||
test -z "$(docker ps -aq --filter name=ql3-worker-rollout-live-)"
|
||||
test -z "$(docker ps -aq --filter name=ql3-worker-rollout-postgres-live-)"
|
||||
docker volume ls --quiet --filter dangling=true | sort > \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
diff --unified \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.before" \
|
||||
"${RUNNER_TEMP}/ql3-dangling-volumes.after"
|
||||
docker system df
|
||||
|
||||
- name: Upload audited content-free Worker evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-worker-kubernetes-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-worker/report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Release readiness
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/release-readiness.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
regression:
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
static:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:front && pnpm build:back && pnpm build:info
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
|
||||
images:
|
||||
needs: [regression, static]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: alpine
|
||||
file: docker/Dockerfile
|
||||
cache: alpine
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
- image: alpine-python3.10
|
||||
file: docker/Dockerfile.310
|
||||
cache: alpine-python3.10
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian-python3.10
|
||||
file: docker/Dockerfile.debian310
|
||||
cache: debian-python3.10
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Record source
|
||||
id: source
|
||||
env:
|
||||
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
|
||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build every release architecture
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
|
||||
- name: Load native image for runtime verification
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
tags: qinglong-readiness:${{ matrix.image }}
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Verify native production runtime without external networking
|
||||
run: |
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian'
|
||||
with:
|
||||
name: release-readiness-debian-image
|
||||
path: release-readiness-debian-image.*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
regression:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cp .env.example .env
|
||||
- run: pnpm test
|
||||
- run: pnpm build:back
|
||||
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: execution-baseline
|
||||
path: execution-baseline.json
|
||||
+12
-3
@@ -2,8 +2,6 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/packages/*/node_modules
|
||||
/packages/*/dist
|
||||
/npm-debug.log*
|
||||
/yarn-error.log
|
||||
/yarn.lock
|
||||
@@ -11,7 +9,6 @@
|
||||
|
||||
# production
|
||||
/static
|
||||
/.ql3-panel-dist
|
||||
/data
|
||||
|
||||
# misc
|
||||
@@ -38,3 +35,15 @@ __pycache__
|
||||
|
||||
# local Kubernetes overlays
|
||||
/deploy/kubernetes/overlays/local/
|
||||
|
||||
# Local security audit artifacts
|
||||
/audit/
|
||||
|
||||
# Local browser tests, screenshots, reports, and temporary test output
|
||||
/.playwright-cli/
|
||||
/output/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/blob-report/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
strict-peer-dependencies=false
|
||||
resolve-peers-from-workspace-root=false
|
||||
strict-peer-dependencies=false
|
||||
@@ -0,0 +1,10 @@
|
||||
# Node.js 原生部署与 Python venv
|
||||
|
||||
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
|
||||
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
|
||||
|
||||
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
|
||||
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
|
||||
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
|
||||
|
||||
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
|
||||
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
- Support system level notification
|
||||
- Support dark mode
|
||||
- Support cell phone operation
|
||||
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
|
||||
|
||||
## Version
|
||||
|
||||
@@ -79,6 +80,29 @@ npm i @whyour/qinglong
|
||||
|
||||
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## Remote CLI and Agent Skill
|
||||
|
||||
Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents.
|
||||
|
||||
Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`:
|
||||
|
||||
```bash
|
||||
npm install -g @whyour/qinglong-cli
|
||||
ql login --url https://ql.example.com
|
||||
ql auth status --json
|
||||
ql task list --json
|
||||
ql subscription list --json
|
||||
ql --help
|
||||
```
|
||||
|
||||
Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help.
|
||||
|
||||
The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md).
|
||||
|
||||
[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md)
|
||||
|
||||
The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
- 支持系统级通知
|
||||
- 支持暗黑模式
|
||||
- 支持手机端操作
|
||||
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -81,6 +82,29 @@ npm i @whyour/qinglong
|
||||
|
||||
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## 远程 CLI 与 Agent Skill
|
||||
|
||||
使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。
|
||||
|
||||
需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令:
|
||||
|
||||
```bash
|
||||
npm install -g @whyour/qinglong-cli
|
||||
ql login --url https://ql.example.com
|
||||
ql auth status --json
|
||||
ql task list --json
|
||||
ql subscription list --json
|
||||
ql --help
|
||||
```
|
||||
|
||||
在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。
|
||||
|
||||
npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。
|
||||
|
||||
[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md)
|
||||
|
||||
包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
diagnoseClientIp,
|
||||
getTrustProxyConfig,
|
||||
updateTrustProxy,
|
||||
} from '../shared/trustProxy';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/system/client-ip', route);
|
||||
|
||||
route.get(
|
||||
'/config',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({ code: 200, data: await getTrustProxyConfig() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
trustProxy: Joi.string().max(500).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await updateTrustProxy(req.body.trustProxy);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
res.send({
|
||||
code: 400,
|
||||
message: error instanceof Error ? error.message : t('配置更新失败'),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/diagnose',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({ code: 200, data: await diagnoseClientIp(req) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+14
-24
@@ -4,11 +4,11 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { join, basename } from 'path';
|
||||
import { SAMPLE_FILES } from '../config/const';
|
||||
import { t } from '../shared/i18n';
|
||||
import ConfigService from '../services/config';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -72,20 +72,15 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const { name, content } = req.body;
|
||||
// Resolve path first to prevent traversal attacks
|
||||
let basePath = config.configPath;
|
||||
if (name.startsWith('data/scripts/')) {
|
||||
basePath = join(config.rootPath, 'data/scripts');
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||
const resolvedPath = join(basePath, cleanName);
|
||||
const normalized = join(resolvedPath);
|
||||
// Verify the resolved path stays within allowed directory
|
||||
if (!normalized.startsWith(basePath)) {
|
||||
return res.send({ code: 403, message: t('文件路径无效') });
|
||||
}
|
||||
// Check blacklist on actual filename (not user input)
|
||||
if (config.blackFileList.includes(basename(normalized))) {
|
||||
const normalized = resolveFileAccess(
|
||||
config.configPath,
|
||||
[name],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!normalized) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(normalized, content);
|
||||
@@ -96,15 +91,10 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configService = Container.get(ConfigService);
|
||||
await configService.getFile(req.params.file, res);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+38
-12
@@ -5,14 +5,31 @@ 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 {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { t } from '../shared/i18n';
|
||||
import cronClient from '../schedule/client';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/crons', route);
|
||||
|
||||
route.use(async (req, res, next) => {
|
||||
// Keep stop/status callbacks available even when the scheduler is down.
|
||||
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
|
||||
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
|
||||
try {
|
||||
await cronClient.readiness.ensureReady();
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/views',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -304,16 +321,34 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const cronService = Container.get(CronService);
|
||||
const result = await cronService.log(req.params.id);
|
||||
const result = await cronService.log(req.params.id, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: result.content,
|
||||
logStatus: result.status,
|
||||
offset: result.offset,
|
||||
nextOffset: result.nextOffset,
|
||||
total: result.total,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -436,11 +471,6 @@ export default (app: Router) => {
|
||||
last_running_time: Joi.number().optional().allow(null),
|
||||
last_execution_time: Joi.number().optional().allow(null),
|
||||
exit_code: Joi.number().optional().allow(null),
|
||||
execution_id: Joi.string()
|
||||
.pattern(
|
||||
/^legacy-system:[1-9][0-9]{0,12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -489,11 +519,7 @@ export default (app: Router) => {
|
||||
instanceId: Joi.number().required(),
|
||||
}),
|
||||
}),
|
||||
async (
|
||||
req: Request<{ id: number; instanceId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
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);
|
||||
|
||||
@@ -107,6 +107,46 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
for (const isSuccess of [false, true]) {
|
||||
route.get(
|
||||
isSuccess ? '/successes' : '/failures',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const countColumn = isSuccess ? 'success_count' : 'fail_count';
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']],
|
||||
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']],
|
||||
raw: true,
|
||||
})) as any[];
|
||||
const crons = rows.length > 0 ? await CrontabModel.findAll({
|
||||
attributes: ['id', 'name', 'command'],
|
||||
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
|
||||
raw: true,
|
||||
}) : [];
|
||||
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
|
||||
res.send({
|
||||
code: 200,
|
||||
data: rows.map((row) => {
|
||||
const id = Number(row.ref_id);
|
||||
const cron = cronMap.get(id);
|
||||
return {
|
||||
id,
|
||||
name: cron?.name || cron?.command || tf('任务#%s', id),
|
||||
command: cron?.command || '',
|
||||
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count),
|
||||
deleted: !cron,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
route.get(
|
||||
'/trend',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
+17
-14
@@ -1,24 +1,16 @@
|
||||
import { Joi, celebrate } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import EnvService from '../services/env';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
},
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
const labelSchema = Joi.array()
|
||||
.items(Joi.string().trim().required())
|
||||
.min(1)
|
||||
@@ -282,12 +274,23 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
|
||||
const parseContent = safeJSONParse(fileContent);
|
||||
let parseContent: unknown;
|
||||
try {
|
||||
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
|
||||
} catch {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
const data = Array.isArray(parseContent)
|
||||
? parseContent
|
||||
: [parseContent];
|
||||
if (data.every((x) => x.name && x.value)) {
|
||||
if (
|
||||
data.length > 0 &&
|
||||
data.every((x) =>
|
||||
x && typeof x.name === 'string' &&
|
||||
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
|
||||
typeof x.value === 'string' && x.value.length > 0,
|
||||
)
|
||||
) {
|
||||
const result = await envService.create(
|
||||
data.map((x) => ({
|
||||
name: x.name,
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ export default (app: Router) => {
|
||||
try {
|
||||
const healthService = Container.get(HealthService);
|
||||
const health = await healthService.check();
|
||||
res.status(200).send({
|
||||
code: 200,
|
||||
res.status(health.status === 'ok' ? 200 : 503).send({
|
||||
code: health.status === 'ok' ? 200 : 503,
|
||||
data: health,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -28,6 +30,8 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
+30
-21
@@ -5,13 +5,13 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
getFileContentByName,
|
||||
readDirs,
|
||||
removeAnsi,
|
||||
rmPath,
|
||||
} from '../config/util';
|
||||
import LogService from '../services/log';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
|
||||
const route = Router();
|
||||
const blacklist = ['.tmp'];
|
||||
|
||||
@@ -34,6 +34,20 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
path: Joi.string().allow('').optional(),
|
||||
file: Joi.string().required(),
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(MAX_LOG_CHUNK_BYTES)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
@@ -52,11 +66,19 @@ export default (app: Router) => {
|
||||
where: { log_path: logPath, status: InstanceStatus.running },
|
||||
});
|
||||
|
||||
const content = await getFileContentByName(finalPath);
|
||||
const chunk = await readLogChunk(finalPath, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
res.send({
|
||||
code: 200,
|
||||
data: removeAnsi(content),
|
||||
data: removeAnsi(chunk.content),
|
||||
logStatus: runningInstance ? 'running' : undefined,
|
||||
offset: chunk.offset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
total: chunk.total,
|
||||
truncated: chunk.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -66,24 +88,11 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(
|
||||
(req.query.path as string) || '',
|
||||
(req.params.file as string) || '',
|
||||
);
|
||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
(req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
||||
import OpenService from '../services/open';
|
||||
import { Logger } from 'winston';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -100,6 +101,7 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/auth/token',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
|
||||
celebrate({
|
||||
query: {
|
||||
client_id: Joi.string().required(),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { Container } from 'typedi';
|
||||
import RetentionService from '../services/retention';
|
||||
import { MAX_RETENTION_DAYS } from '../shared/retention';
|
||||
|
||||
const route = Router();
|
||||
const policySchema = {
|
||||
runningInstanceRetentionDays: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(MAX_RETENTION_DAYS)
|
||||
.required(),
|
||||
cronStatRetentionDays: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(MAX_RETENTION_DAYS)
|
||||
.required(),
|
||||
};
|
||||
const cleanupSchema = {
|
||||
...policySchema,
|
||||
dependenceCacheTypes: Joi.array()
|
||||
.items(Joi.string().valid('node', 'python3'))
|
||||
.unique()
|
||||
.default([]),
|
||||
compactDatabase: Joi.boolean().default(false),
|
||||
};
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/system/storage-retention', route);
|
||||
|
||||
route.put(
|
||||
'/config',
|
||||
celebrate({ body: Joi.object(policySchema) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.updatePolicy(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/preview',
|
||||
celebrate({ body: Joi.object(cleanupSchema) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.preview(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/cleanup',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...cleanupSchema,
|
||||
confirmation: Joi.string().valid('CLEAN').required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.cleanup(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+216
-40
@@ -1,37 +1,74 @@
|
||||
import {
|
||||
fileExist,
|
||||
isPathInside,
|
||||
readDirs,
|
||||
readDir,
|
||||
rmPath,
|
||||
IFile,
|
||||
} from '../config/util';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import path, { dirname, join, parse, resolve, sep } from 'path';
|
||||
import ScriptService from '../services/script';
|
||||
import ScriptHistoryService from '../services/scriptHistory';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
ScriptHistoryError,
|
||||
HistoryUnavailableError,
|
||||
} from '../shared/scriptHistory';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((rootPath) =>
|
||||
isPathInside(rootPath, resolved),
|
||||
return config.writePathList.some((x) =>
|
||||
Boolean(
|
||||
resolveFileAccess(
|
||||
x,
|
||||
[resolved],
|
||||
// Panel configuration secrets must not restrict user script filenames.
|
||||
path.resolve(x) === path.resolve(config.configPath)
|
||||
? config.blackFileList
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function existingWritableRealPath(targetPath: string): Promise<string> {
|
||||
if (!isPathAllowed(targetPath)) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
const resolved = resolve(targetPath);
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
// Keep an explicit normalized boundary check at the filesystem operation.
|
||||
if (!resolved.startsWith(rootPrefix)) continue;
|
||||
const realPath = await fs.realpath(resolved);
|
||||
const realRoot = await fs.realpath(root);
|
||||
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
||||
if (
|
||||
!realPath.startsWith(realRootPrefix) ||
|
||||
!resolveFileAccess(
|
||||
realRoot,
|
||||
[realPath],
|
||||
root === resolve(config.configPath) ? config.blackFileList : [],
|
||||
)
|
||||
) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
cb(null, config.tmpPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
cb(null, randomUUID());
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
@@ -59,6 +96,11 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
@@ -86,7 +128,8 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
@@ -100,7 +143,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query?.path as string || '',
|
||||
(req.query?.path as string) || '',
|
||||
req.query.file as string,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
@@ -110,38 +153,108 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
const historyError = (error: unknown, res: Response, next: NextFunction) => {
|
||||
if (error instanceof ScriptHistoryError) {
|
||||
return res.status(error.status).send({
|
||||
code: error.status,
|
||||
message: t(error.message),
|
||||
...(error instanceof HistoryUnavailableError
|
||||
? { historyUnavailable: true, currentHash: error.currentHash }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return next(error);
|
||||
};
|
||||
const historyQuery = {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
};
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
'/history',
|
||||
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.list(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get(
|
||||
'/history/detail',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query?.path as string || '',
|
||||
req.params.file,
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.detail(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
req.query.id as string,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.put(
|
||||
'/history/restore',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
expectedHash: Joi.string().hex().length(64).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { path = '', filename, id, expectedHash } = req.body;
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.restore(path, filename, id, expectedHash);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||
});
|
||||
});
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
res.on('finish', () => {
|
||||
if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined);
|
||||
});
|
||||
next();
|
||||
},
|
||||
upload.single('file'),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().optional().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
originFilename: Joi.string().optional().allow(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
@@ -167,11 +280,7 @@ export default (app: Router) => {
|
||||
if (!path.startsWith('/')) {
|
||||
path = join(config.scriptPath, path);
|
||||
}
|
||||
if (
|
||||
config.writePathList.every(
|
||||
(rootPath) => !isPathInside(rootPath, path),
|
||||
)
|
||||
) {
|
||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
@@ -183,7 +292,8 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(uploadPath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.rename(req.file.path, uploadPath);
|
||||
await fs.copyFile(req.file.path, uploadPath);
|
||||
await fs.unlink(req.file.path);
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
|
||||
@@ -204,8 +314,56 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
// Check the canonical parent directly at the directory creation boundary.
|
||||
const parentPath = resolve(dirname(filePath));
|
||||
let parentCreated = false;
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
if (parentPath === root) {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
} else if (parentPath.startsWith(rootPrefix)) {
|
||||
await fs.mkdir(parentPath, { recursive: true });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
parentCreated = true;
|
||||
break;
|
||||
}
|
||||
if (!parentCreated) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
|
||||
let removeSource = filename !== originFilename;
|
||||
let backupSource = '';
|
||||
if (removeSource) {
|
||||
const [originRealPath, targetRealPath] = await Promise.all([
|
||||
existingWritableRealPath(originFilePath),
|
||||
existingWritableRealPath(filePath),
|
||||
]);
|
||||
// Aliases of one script are an in-place save. Removing the source
|
||||
// would also remove the destination behind a target symlink.
|
||||
removeSource = originRealPath !== targetRealPath;
|
||||
backupSource = originRealPath;
|
||||
}
|
||||
// Save-as removes the source after committing the destination. Keep
|
||||
// its original content too: destination history only protects the
|
||||
// file being overwritten, not the source being deleted.
|
||||
if (removeSource) {
|
||||
await fs.copyFile(
|
||||
backupSource,
|
||||
join(config.bakPath, originFilename.replace(/\//g, '')),
|
||||
);
|
||||
}
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path, filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
if (removeSource) await rmPath(originFilePath);
|
||||
return res.send({ code: 200, data });
|
||||
}
|
||||
if (fileExists) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -218,7 +376,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -230,6 +388,11 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().required().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -247,10 +410,14 @@ export default (app: Router) => {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path || '', filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -377,12 +544,21 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
||||
const logPath = resolveFileAccess(config.logPath, [
|
||||
path,
|
||||
`${name}.swap`,
|
||||
]);
|
||||
if (!logPath) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const result = await scriptService.stopScript(filePath, pid);
|
||||
setTimeout(() => {
|
||||
rmPath(logPath);
|
||||
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (cleanupPath) {
|
||||
void rmPath(cleanupPath);
|
||||
}
|
||||
}, 3000);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
|
||||
+19
-12
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -58,10 +58,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -148,13 +145,27 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.log(req.params.id);
|
||||
return res.send({ code: 200, data });
|
||||
const result = await subscriptionService.log(req.params.id, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
return res.send({ code: 200, data: result.content, ...result });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -190,11 +201,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
typeof req.body.schedule === 'object' ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
|
||||
+48
-10
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
|
||||
import UserService from '../services/user';
|
||||
import { t } from '../shared/i18n';
|
||||
import { isDefaultAuthInfo } from '../shared/auth';
|
||||
import {
|
||||
getUniqPath,
|
||||
handleLogPath,
|
||||
parseVersion,
|
||||
promiseExec,
|
||||
} from '../config/util';
|
||||
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
|
||||
import { findCronId } from '../shared/cronCommand';
|
||||
import dayjs from 'dayjs';
|
||||
import multer from 'multer';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
const route = Router();
|
||||
const storage = multer.diskStorage({
|
||||
@@ -208,6 +205,17 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/update-status',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send(await Container.get(SystemService).getUpgradeStatus());
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/reload',
|
||||
celebrate({
|
||||
@@ -249,6 +257,7 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/command-run',
|
||||
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
command: Joi.string().required(),
|
||||
@@ -258,8 +267,13 @@ export default (app: Router) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const command = req.body.command;
|
||||
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
|
||||
let id = await promiseExec(idStr);
|
||||
const crontab = await fs
|
||||
.readFile(config.crontabFile, 'utf8')
|
||||
.catch((e) => {
|
||||
if (e.code === 'ENOENT') return '';
|
||||
throw e;
|
||||
});
|
||||
const id = findCronId(crontab, command);
|
||||
const uniqPath = await getUniqPath(command, id);
|
||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
@@ -273,8 +287,13 @@ export default (app: Router) => {
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
res.end();
|
||||
try {
|
||||
await logStreamManager.closeStream(
|
||||
await handleLogPath(logPath),
|
||||
);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(message);
|
||||
@@ -350,6 +369,11 @@ export default (app: Router) => {
|
||||
query: {
|
||||
startTime: Joi.string().allow('').optional(),
|
||||
endTime: Joi.string().allow('').optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
t: Joi.string().optional(),
|
||||
},
|
||||
}),
|
||||
@@ -361,6 +385,7 @@ export default (app: Router) => {
|
||||
req.query as {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -369,6 +394,19 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/notify-log',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const data = await systemService.getNotifyLog();
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/log',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
+86
-13
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
|
||||
cb(null, key + ext);
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const imageTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
};
|
||||
if (imageTypes[ext] !== file.mimetype) {
|
||||
return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片')));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/user', route);
|
||||
@@ -35,8 +53,8 @@ export default (app: Router) => {
|
||||
}),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -70,8 +88,8 @@ export default (app: Router) => {
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -80,8 +98,8 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -156,11 +174,12 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/two-factor/login',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
code: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -189,6 +208,59 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/ip-blacklist',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.getIpBlacklist();
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/ip-blacklist',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ip: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.blockIp(req.body.ip);
|
||||
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/ip-blacklist',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ip: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.unblockIp(req.body.ip);
|
||||
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/notification',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -219,18 +291,19 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/init',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
const result = await userService.initializeUser(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+40
-98
@@ -1,14 +1,11 @@
|
||||
import 'reflect-metadata';
|
||||
import cluster, { type Worker } from 'cluster';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import config from './config';
|
||||
import Logger from './loaders/logger';
|
||||
import { monitoringMiddleware } from './middlewares/monitoring';
|
||||
import { errStack } from './config/util';
|
||||
import { errStack } from './shared/errors';
|
||||
import { initializeLanguageSync } from './shared/languageSync';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
|
||||
@@ -20,35 +17,18 @@ interface WorkerMetadata {
|
||||
}
|
||||
|
||||
class Application {
|
||||
private app: express.Application;
|
||||
private httpServerService?: HttpServerService;
|
||||
private grpcServerService?: GrpcServerService;
|
||||
private manualPrimaryRuntime?: {
|
||||
stop(): Promise<'drained' | 'timed_out'>;
|
||||
};
|
||||
private legacyShadowCaptureEvidence?: {
|
||||
close(): Promise<unknown>;
|
||||
};
|
||||
private isShuttingDown = false;
|
||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||
private httpWorker?: Worker;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
// 创建一个全局中间件,删除查询参数中的t
|
||||
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (req.query.t) {
|
||||
delete req.query.t;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
if (cluster.isPrimary) {
|
||||
await this.initializeDatabase();
|
||||
}
|
||||
await initializeLanguageSync();
|
||||
if (cluster.isPrimary) {
|
||||
this.startMasterProcess();
|
||||
} else {
|
||||
@@ -61,11 +41,18 @@ class Application {
|
||||
}
|
||||
|
||||
private startMasterProcess() {
|
||||
const readyTimeout =
|
||||
process.arch === 'arm' &&
|
||||
Number(
|
||||
(process.config.variables as { arm_version?: string | number }).arm_version,
|
||||
) === 6
|
||||
? 120000
|
||||
: 30000;
|
||||
// Fork gRPC worker first and wait for it to be ready
|
||||
const grpcWorker = this.forkWorker('grpc');
|
||||
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
this.waitForWorkerReady(grpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
@@ -85,8 +72,13 @@ class Application {
|
||||
);
|
||||
// If gRPC worker died, restart it and wait for it to be ready
|
||||
if (metadata.serviceType === 'grpc') {
|
||||
try {
|
||||
this.httpWorker?.send('scheduler-unavailable');
|
||||
} catch (error) {
|
||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||
}
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('gRPC worker restarted and ready');
|
||||
// Re-register cron jobs by notifying the HTTP worker
|
||||
@@ -138,7 +130,14 @@ class Application {
|
||||
}
|
||||
|
||||
private forkWorker(serviceType: string): Worker {
|
||||
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
||||
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
|
||||
// PM2's fork launcher is inherited by our own cluster workers. Their APM
|
||||
// messages go to this primary, not PM2, and duplicate its sampling work.
|
||||
// Keep primary monitoring and allow restoring the inherited worker APM.
|
||||
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
|
||||
workerEnv.pmx = 'false';
|
||||
}
|
||||
const worker = cluster.fork(workerEnv);
|
||||
|
||||
this.workerMetadataMap.set(worker.id, {
|
||||
id: worker.id,
|
||||
@@ -151,17 +150,13 @@ class Application {
|
||||
}
|
||||
|
||||
private async initializeDatabase() {
|
||||
const dbLoader = await import('./loaders/db');
|
||||
await dbLoader.default();
|
||||
const { runStartupProcess } = await import('./shared/startupProcess');
|
||||
await runStartupProcess(require.resolve('./bootstrap/database'));
|
||||
}
|
||||
|
||||
private setupMiddlewares() {
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
this.app.use(cors(config.cors));
|
||||
this.app.use(compression());
|
||||
this.app.use(monitoringMiddleware);
|
||||
private async setupMiddlewares(): Promise<express.Application> {
|
||||
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||
return createHttpApplication();
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
@@ -240,41 +235,18 @@ class Application {
|
||||
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||
await initGrpcCerts();
|
||||
|
||||
this.setupMiddlewares();
|
||||
const app = await this.setupMiddlewares();
|
||||
|
||||
const { HttpServerService } = await import('./services/http');
|
||||
this.httpServerService = Container.get(HttpServerService);
|
||||
|
||||
const appLoader = await import('./loaders/app');
|
||||
await appLoader.default({ app: this.app });
|
||||
await appLoader.default({ app });
|
||||
|
||||
const { bootstrapLegacyShadowStartupReconciliation } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
|
||||
const server = await this.httpServerService.initialize(
|
||||
app,
|
||||
config.port,
|
||||
);
|
||||
const legacyShadowStartup =
|
||||
await bootstrapLegacyShadowStartupReconciliation();
|
||||
|
||||
const { bootstrapLegacyShadowCaptureEvidence } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence'
|
||||
);
|
||||
this.legacyShadowCaptureEvidence =
|
||||
await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: legacyShadowStartup,
|
||||
});
|
||||
|
||||
const { bootstrapDefaultManualPrimaryRuntime } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime'
|
||||
);
|
||||
this.manualPrimaryRuntime = await bootstrapDefaultManualPrimaryRuntime();
|
||||
|
||||
let server;
|
||||
try {
|
||||
server = await this.httpServerService.initialize(this.app, config.port);
|
||||
} catch (error) {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const serverLoader = await import('./loaders/server');
|
||||
await (serverLoader.default as any)({ server });
|
||||
@@ -293,17 +265,10 @@ class Application {
|
||||
process.on('message', async (msg) => {
|
||||
if (msg === 'shutdown') {
|
||||
this.gracefulShutdown(serviceType);
|
||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||
// Re-register cron jobs when gRPC worker restarts
|
||||
try {
|
||||
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||
const CronService = (await import('./services/cron')).default;
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.autosave_crontab();
|
||||
Logger.info('[boot] Cron jobs re-registered successfully');
|
||||
} catch (error) {
|
||||
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
|
||||
}
|
||||
} else if (serviceType === 'http' &&
|
||||
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
|
||||
const { default: cronClient } = await import('./schedule/client');
|
||||
cronClient.readiness.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -318,9 +283,7 @@ class Application {
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.httpServerService?.shutdown();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
} else {
|
||||
await this.grpcServerService?.shutdown();
|
||||
}
|
||||
@@ -330,27 +293,6 @@ class Application {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopManualPrimaryRuntime(): Promise<void> {
|
||||
const runtime = this.manualPrimaryRuntime;
|
||||
this.manualPrimaryRuntime = undefined;
|
||||
if (!runtime) return;
|
||||
try {
|
||||
const result = await runtime.stop();
|
||||
if (result === 'timed_out') {
|
||||
Logger.warn('[runtime-activation] shutdown timed out');
|
||||
}
|
||||
} catch {
|
||||
Logger.error('[runtime-activation] shutdown failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async closeLegacyShadowCaptureEvidence(): Promise<void> {
|
||||
const evidence = this.legacyShadowCaptureEvidence;
|
||||
this.legacyShadowCaptureEvidence = undefined;
|
||||
if (!evidence) return;
|
||||
await evidence.close();
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Application();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { errStack } from '../shared/errors';
|
||||
|
||||
// Also terminate if the primary disappears without forwarding a signal.
|
||||
const onParentDisconnect = () => process.exit(1);
|
||||
process.once('disconnect', onParentDisconnect);
|
||||
|
||||
async function initializeDatabase() {
|
||||
const { sequelize } = await import('../data');
|
||||
try {
|
||||
const { default: loadDatabase } = await import('../loaders/db');
|
||||
await loadDatabase();
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
initializeDatabase()
|
||||
.catch((error) => {
|
||||
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
process.removeListener('disconnect', onParentDisconnect);
|
||||
// Let pending log writes drain and exit naturally after closing SQLite.
|
||||
if (process.connected) process.disconnect();
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// A single, disposable backend process. Resolve startup dependencies, but never
|
||||
// run appLoader: its initData/initTask hooks install dependencies and start user work.
|
||||
import 'reflect-metadata';
|
||||
import dns from 'dns';
|
||||
import http from 'http';
|
||||
|
||||
async function probeUpgrade() {
|
||||
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
const { default: config } = await import('../config');
|
||||
const { sequelize } = await import('../data');
|
||||
const { default: loadDatabase } = await import('../loaders/db');
|
||||
const { Container } = await import('typedi');
|
||||
const { GrpcServerService } = await import('../services/grpc');
|
||||
const { HttpServerService } = await import('../services/http');
|
||||
const { createHttpApplication } = await import('../loaders/httpApplication');
|
||||
const { default: loadServer } = await import('../loaders/server');
|
||||
const { default: loadExpress } = await import('../loaders/express');
|
||||
await import('../loaders/app'); // Resolve normal startup hooks without running them.
|
||||
const { default: inject } = await import('../loaders/depInjector');
|
||||
const grpc = Container.get(GrpcServerService);
|
||||
const server = Container.get(HttpServerService);
|
||||
try {
|
||||
await loadDatabase();
|
||||
// Exercise an actual on-disk transaction as well as schema initialization.
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
|
||||
transaction,
|
||||
});
|
||||
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
|
||||
transaction,
|
||||
});
|
||||
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
|
||||
transaction,
|
||||
});
|
||||
if ((rows[0] as { value: number })?.value !== 42)
|
||||
throw new Error('Upgrade database readback failed');
|
||||
});
|
||||
inject();
|
||||
config.grpcPort = await grpc.initialize();
|
||||
const { default: cronClient } = await import('../schedule/client');
|
||||
cronClient.readiness.configure(async () => {});
|
||||
if (!(await cronClient.readiness.recover()))
|
||||
throw new Error('Upgrade scheduler readiness failed');
|
||||
const app = await createHttpApplication();
|
||||
loadExpress({ app });
|
||||
const listener = await server.initialize(app, 0);
|
||||
await loadServer({ server: listener });
|
||||
const address = listener.address();
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('No probe HTTP port');
|
||||
const health = await new Promise<any>((resolve, reject) => {
|
||||
const request = http.get(
|
||||
`http://127.0.0.1:${address.port}/api/health`,
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
result.code !== 200 ||
|
||||
result.data?.status !== 'ok' ||
|
||||
!result.data.services?.grpc ||
|
||||
!result.data.services?.http
|
||||
)
|
||||
throw new Error('Upgrade HTTP/gRPC health check failed');
|
||||
resolve(result.data);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.setTimeout(10000, () =>
|
||||
request.destroy(new Error('Upgrade health request timed out')),
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
process.send!({
|
||||
type: 'upgrade-probe',
|
||||
ok: true,
|
||||
build: health.build,
|
||||
memory: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
});
|
||||
} finally {
|
||||
await server.shutdown();
|
||||
grpc.getServer().forceShutdown();
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
process.once('disconnect', () => process.exit(1));
|
||||
probeUpgrade()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
process.send?.({
|
||||
type: 'upgrade-probe',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export const SAMPLE_FILES = [
|
||||
];
|
||||
|
||||
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
|
||||
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
|
||||
|
||||
export const NotificationModeStringMap = {
|
||||
0: 'gotify',
|
||||
@@ -51,6 +52,7 @@ export const NotificationModeStringMap = {
|
||||
19: 'ntfy',
|
||||
20: 'wxPusherBot',
|
||||
21: 'wxPusherSpt',
|
||||
22: 'wpush',
|
||||
} as const;
|
||||
|
||||
export const LINUX_DEPENDENCE_COMMAND: Record<
|
||||
|
||||
+93
-45
@@ -1,6 +1,5 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
import config from './index';
|
||||
import { fileExist } from './util';
|
||||
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
|
||||
|
||||
let cachedConfig: GrpcTlsConfig | null = null;
|
||||
|
||||
function run(cmd: string, execOpts?: Record<string, unknown>): string {
|
||||
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
|
||||
return (execSync(cmd, opts) as string).trim();
|
||||
}
|
||||
|
||||
async function tmpFile(prefix: string): Promise<string> {
|
||||
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
|
||||
function run(args: string[]): string {
|
||||
return execFileSync('openssl', args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||
|
||||
const caKeyTmp = await tmpFile('ca_key');
|
||||
const caCertTmp = await tmpFile('ca_cert');
|
||||
const serverKeyTmp = await tmpFile('server_key');
|
||||
const serverCsrTmp = await tmpFile('server_csr');
|
||||
const serverExtTmp = await tmpFile('server_ext');
|
||||
const clientKeyTmp = await tmpFile('client_key');
|
||||
const clientCsrTmp = await tmpFile('client_csr');
|
||||
const clientExtTmp = await tmpFile('client_ext');
|
||||
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
|
||||
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
|
||||
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
|
||||
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
|
||||
const caKeyTmp = path.join(workspace, 'ca.key');
|
||||
const caCertTmp = path.join(workspace, 'ca.crt');
|
||||
const serverKeyTmp = path.join(workspace, 'server.key');
|
||||
const serverCsrTmp = path.join(workspace, 'server.csr');
|
||||
const serverExtTmp = path.join(workspace, 'server.ext');
|
||||
const clientKeyTmp = path.join(workspace, 'client.key');
|
||||
const clientCsrTmp = path.join(workspace, 'client.csr');
|
||||
const clientExtTmp = path.join(workspace, 'client.ext');
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
||||
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
||||
try { await fs.unlink(f); } catch {}
|
||||
}
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
|
||||
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
|
||||
run(['genrsa', '-out', caKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-x509',
|
||||
'-days',
|
||||
'3650',
|
||||
'-key',
|
||||
caKeyTmp,
|
||||
'-out',
|
||||
caCertTmp,
|
||||
'-subj',
|
||||
'/CN=qinglong-ca/O=qinglong/C=CN',
|
||||
]);
|
||||
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||
await fs.mkdir(certDir, { recursive: true });
|
||||
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||
|
||||
// 2. 服务端
|
||||
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
|
||||
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
|
||||
const serverCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
|
||||
run(['genrsa', '-out', serverKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
serverKeyTmp,
|
||||
'-out',
|
||||
serverCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-server',
|
||||
]);
|
||||
await fs.writeFile(
|
||||
serverExtTmp,
|
||||
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
|
||||
);
|
||||
const serverCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
serverCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
serverExtTmp,
|
||||
]);
|
||||
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||
|
||||
// 3. 客户端
|
||||
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
|
||||
run(['genrsa', '-out', clientKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
clientKeyTmp,
|
||||
'-out',
|
||||
clientCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-client',
|
||||
]);
|
||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||
const clientCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
||||
);
|
||||
const clientCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
clientCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
clientExtTmp,
|
||||
]);
|
||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||
|
||||
await cleanup();
|
||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
} catch (e) {
|
||||
} finally {
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
export function createRandomString(min: number, max: number): string {
|
||||
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const english = [
|
||||
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
|
||||
arr.push(getOne(ENGLISH));
|
||||
arr.push(getOne(special));
|
||||
|
||||
const len = min + Math.floor(Math.random() * (max - min + 1));
|
||||
const len = min + randomInt(max - min + 1);
|
||||
|
||||
for (let i = 4; i < len; i++) {
|
||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
||||
arr.push(config[randomInt(config.length)]);
|
||||
}
|
||||
|
||||
const newArr = [];
|
||||
for (let j = 0; j < len; j++) {
|
||||
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
|
||||
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
|
||||
}
|
||||
|
||||
function getOne(arr: any[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
return arr[randomInt(arr.length)];
|
||||
}
|
||||
|
||||
return newArr.join('');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-26
@@ -1,5 +1,7 @@
|
||||
import { Subscription } from '../data/subscription';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export function formatUrl(doc: Subscription) {
|
||||
let url = doc.url;
|
||||
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
|
||||
if (doc.type === 'private-repo') {
|
||||
if (doc.pull_type === 'ssh-key') {
|
||||
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
||||
url = doc.url!.replace(host, doc.alias);
|
||||
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
|
||||
} else {
|
||||
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
||||
const { username, password } = doc.pull_option as any;
|
||||
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
|
||||
}
|
||||
|
||||
export function formatCommand(doc: Subscription, url?: string) {
|
||||
let command = `SUB_ID=${doc.id} ql `;
|
||||
let _url = url || formatUrl(doc).url;
|
||||
const {
|
||||
type,
|
||||
whitelist,
|
||||
blacklist,
|
||||
dependences,
|
||||
branch,
|
||||
extensions,
|
||||
proxy,
|
||||
autoAddCron,
|
||||
autoDelCron,
|
||||
} = doc;
|
||||
if (type === 'file') {
|
||||
command += `raw "${_url}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
} else {
|
||||
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
|
||||
dependences || ''
|
||||
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
}
|
||||
return command;
|
||||
const args =
|
||||
doc.type === 'file'
|
||||
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
|
||||
: [
|
||||
'repo',
|
||||
url || formatUrl(doc).url,
|
||||
doc.whitelist || '',
|
||||
doc.blacklist || '',
|
||||
doc.dependences || '',
|
||||
doc.branch || '',
|
||||
doc.extensions || '',
|
||||
doc.proxy || '',
|
||||
];
|
||||
args.push(
|
||||
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
|
||||
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
|
||||
);
|
||||
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
|
||||
.map((arg) => shellQuote(arg!))
|
||||
.join(' ')}`;
|
||||
}
|
||||
|
||||
+383
-180
@@ -1,17 +1,25 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { statSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import psTreeFun from 'ps-tree';
|
||||
import { promisify } from 'util';
|
||||
import { load } from 'js-yaml';
|
||||
import config from './index';
|
||||
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
|
||||
import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
|
||||
import Logger from '../loaders/logger';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { DependenceTypes } from '../data/dependence';
|
||||
import { FormData } from 'undici';
|
||||
import os from 'os';
|
||||
import { maybeSudo, isInContainer } from './container';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import {
|
||||
mirrorWriteCommand,
|
||||
normalizeMirror,
|
||||
rewriteMirrorSources,
|
||||
} from '../shared/linuxMirror';
|
||||
|
||||
export * from './share';
|
||||
|
||||
@@ -144,7 +152,8 @@ export async function handleLogPath(
|
||||
logPath: string,
|
||||
data: string = '',
|
||||
): Promise<string> {
|
||||
const absolutePath = path.resolve(config.logPath, logPath);
|
||||
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (!absolutePath) throw new Error('Log path is outside the log directory');
|
||||
const logFileExist = await fileExist(absolutePath);
|
||||
if (!logFileExist) {
|
||||
await createFile(absolutePath, data);
|
||||
@@ -214,48 +223,105 @@ export function dirSort(a: IFile, b: IFile): number {
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_SYSTEM_READ_CONCURRENCY = 32;
|
||||
|
||||
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
|
||||
|
||||
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
|
||||
let activeCount = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
const runNext = () => {
|
||||
while (activeCount < concurrency && queue.length > 0) {
|
||||
activeCount += 1;
|
||||
queue.shift()?.();
|
||||
}
|
||||
};
|
||||
|
||||
return <T>(task: () => Promise<T>) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
queue.push(() => {
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeCount -= 1;
|
||||
runNext();
|
||||
});
|
||||
});
|
||||
runNext();
|
||||
});
|
||||
}
|
||||
|
||||
async function readDirsWithRunner(
|
||||
dir: string,
|
||||
baseDir: string,
|
||||
blacklist: string[],
|
||||
sort: (a: IFile, b: IFile) => number,
|
||||
runFileSystemTask: FileSystemTaskRunner,
|
||||
): Promise<IFile[]> {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const entries = await runFileSystemTask(() =>
|
||||
fs.readdir(dir, { withFileTypes: true }),
|
||||
);
|
||||
|
||||
const items = await Promise.all(
|
||||
entries.map(async (entry): Promise<IFile | undefined> => {
|
||||
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subPath = path.join(dir, entry.name);
|
||||
const stats = await runFileSystemTask(() => fs.lstat(subPath));
|
||||
if (stats.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
const key = path.join(relativePath, entry.name);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const children = await readDirsWithRunner(
|
||||
subPath,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
runFileSystemTask,
|
||||
);
|
||||
return {
|
||||
title: entry.name,
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: entry.name,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
|
||||
}
|
||||
|
||||
export async function readDirs(
|
||||
dir: string,
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
sort: (a: IFile, b: IFile) => number = dirSort,
|
||||
): Promise<IFile[]> {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const files = await fs.readdir(dir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const subPath = path.join(dir, file);
|
||||
const stats = await fs.lstat(subPath);
|
||||
const key = path.join(relativePath, file);
|
||||
|
||||
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const children = await readDirs(subPath, baseDir, blacklist, sort);
|
||||
result.push({
|
||||
title: file,
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children: children.sort(sort),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
title: file,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort(sort);
|
||||
return readDirsWithRunner(
|
||||
dir,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readDir(
|
||||
@@ -263,26 +329,13 @@ export async function readDir(
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
): Promise<IFile[]> {
|
||||
const absoluteBaseDir = path.resolve(baseDir);
|
||||
const absoluteDir = path.resolve(baseDir, dir);
|
||||
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
|
||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||
return [];
|
||||
}
|
||||
const relativePath = path.relative(baseDir, absoluteDir);
|
||||
|
||||
try {
|
||||
const [realBaseDir, realDirectory] = await Promise.all([
|
||||
fs.realpath(absoluteBaseDir),
|
||||
fs.realpath(absoluteDir),
|
||||
]);
|
||||
const directoryStat = await fs.lstat(absoluteDir);
|
||||
if (
|
||||
!isPathInside(realBaseDir, realDirectory) ||
|
||||
!directoryStat.isDirectory() ||
|
||||
directoryStat.isSymbolicLink()
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const relativePath = path.relative(absoluteBaseDir, absoluteDir);
|
||||
const files = await fs.readdir(absoluteDir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
@@ -325,19 +378,6 @@ export async function readDir(
|
||||
}
|
||||
}
|
||||
|
||||
export function isPathInside(rootPath: string, targetPath: string): boolean {
|
||||
const relative = path.relative(
|
||||
path.resolve(rootPath),
|
||||
path.resolve(targetPath),
|
||||
);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export async function promiseExec(command: string): Promise<string> {
|
||||
try {
|
||||
const { stderr, stdout } = await promisify(exec)(command, {
|
||||
@@ -456,18 +496,69 @@ export function psTree(pid: number): Promise<number[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function killTask(pid: number) {
|
||||
const pids = await psTree(pid);
|
||||
|
||||
if (pids.length) {
|
||||
export async function killTask(pid: number, waitForExit = false) {
|
||||
const descendants = await psTree(pid);
|
||||
const signal = (target: number, sig: NodeJS.Signals) => {
|
||||
try {
|
||||
[pid, ...pids].reverse().forEach((x) => {
|
||||
process.kill(x, 15);
|
||||
});
|
||||
} catch (error) { }
|
||||
} else {
|
||||
process.kill(pid, 2);
|
||||
process.kill(target, sig);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
if (!waitForExit) {
|
||||
if (descendants.length) {
|
||||
// A child may exit after psTree; keep signalling the remaining tree.
|
||||
for (const target of [pid, ...descendants].reverse()) {
|
||||
signal(target, 'SIGTERM');
|
||||
}
|
||||
} else signal(pid, 'SIGINT');
|
||||
return;
|
||||
}
|
||||
const pids = [...descendants.reverse(), pid];
|
||||
for (const target of pids) signal(target, 'SIGTERM');
|
||||
const alive = async (target: number) => {
|
||||
try {
|
||||
process.kill(target, 0);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8');
|
||||
// The command field may contain spaces and parentheses. Zombies have
|
||||
// exited even while their parent has not reaped the PID yet.
|
||||
const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0];
|
||||
if (['Z', 'X', 'x'].includes(state)) return false;
|
||||
} catch {
|
||||
// /proc may be unavailable, or the process may have just exited.
|
||||
// Retain the portable signal probe rather than assuming it is dead.
|
||||
try {
|
||||
process.kill(target, 0);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const wait = async () => {
|
||||
const deadline = Date.now() + 1000;
|
||||
let remaining = pids;
|
||||
while (true) {
|
||||
const states = await Promise.all(remaining.map(alive));
|
||||
remaining = remaining.filter((_, index) => states[index]);
|
||||
if (!remaining.length || Date.now() >= deadline) return remaining;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
};
|
||||
let remaining = await wait();
|
||||
if (!remaining.length) return;
|
||||
for (const target of remaining) signal(target, 'SIGKILL');
|
||||
remaining = await wait();
|
||||
if (remaining.length)
|
||||
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
|
||||
}
|
||||
|
||||
export async function getPid(cmd: string) {
|
||||
@@ -567,11 +658,7 @@ export function safeJSONParse(value?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function errStack(error: unknown): string {
|
||||
return error instanceof Error && error.stack
|
||||
? error.stack
|
||||
: String(error);
|
||||
}
|
||||
export { errStack } from '../shared/errors';
|
||||
|
||||
export async function rmPath(path: string) {
|
||||
try {
|
||||
@@ -590,7 +677,9 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
||||
throw new Error('Invalid timezone');
|
||||
}
|
||||
|
||||
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
|
||||
await promiseExec(
|
||||
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
|
||||
);
|
||||
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
|
||||
|
||||
return true;
|
||||
@@ -600,61 +689,177 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a name is a GitHub URL
|
||||
function getPythonExecutable(): string {
|
||||
return PYTHON_VENV_DIR
|
||||
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
|
||||
: 'python3';
|
||||
}
|
||||
|
||||
function getPythonPip(): string {
|
||||
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
|
||||
}
|
||||
|
||||
function isGitHubUrl(name: string): boolean {
|
||||
// Support git+https://, git+http://, https://, and http:// URLs
|
||||
// This covers GitHub URLs and other git-compatible repositories
|
||||
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
|
||||
}
|
||||
|
||||
// Helper function to check if a name is a requirements file
|
||||
function isRequirementsFile(name: string): boolean {
|
||||
return !!name.match(/requirements.*\.(txt|in)$/i);
|
||||
}
|
||||
|
||||
// Helper function to check if a name is a pyproject.toml file
|
||||
function isPyprojectToml(name: string): boolean {
|
||||
return name.endsWith('pyproject.toml');
|
||||
}
|
||||
|
||||
export function isPythonSourceDependency(name: string): boolean {
|
||||
const source = name.trim();
|
||||
return (
|
||||
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
|
||||
);
|
||||
}
|
||||
|
||||
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// For Python dependencies installed from GitHub or requirements files,
|
||||
// we can't reliably check if they're installed, so skip the check
|
||||
if (type === DependenceTypes.python3) {
|
||||
if (isPythonSourceDependency(trimmedName)) {
|
||||
// Return a command that will always indicate not installed
|
||||
// This ensures GitHub URLs and requirements files are always installed
|
||||
return 'echo ""';
|
||||
}
|
||||
}
|
||||
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
||||
[DependenceTypes.python3]: `
|
||||
python3 -c "exec('''
|
||||
name='${name}'
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||
`from importlib.metadata import version, PackageNotFoundError
|
||||
import importlib.util
|
||||
import sys
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
print(version(name))
|
||||
except:
|
||||
import importlib.util as u
|
||||
import importlib.metadata as m
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${name}`
|
||||
: maybeSudo(`dpkg-query -s ${name}`),
|
||||
try:
|
||||
from packaging.requirements import Requirement
|
||||
except ImportError:
|
||||
from pip._vendor.packaging.requirements import Requirement
|
||||
requirement = Requirement(sys.argv[1])
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
print('not applicable')
|
||||
else:
|
||||
try:
|
||||
installed = version(requirement.name)
|
||||
except PackageNotFoundError:
|
||||
if not requirement.specifier and not requirement.extras and not requirement.url:
|
||||
if importlib.util.find_spec(requirement.name):
|
||||
print(requirement.name)
|
||||
else:
|
||||
if requirement.specifier.contains(installed, prereleases=True):
|
||||
print(installed)
|
||||
except Exception:
|
||||
pass`,
|
||||
)} ${shellQuote(trimmedName)}`,
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${trimmedName}`
|
||||
: maybeSudo(`dpkg-query -s ${trimmedName}`),
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
}
|
||||
|
||||
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
const prefix =
|
||||
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
|
||||
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
|
||||
: '';
|
||||
const pip = `${getPythonPip()} install --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
}${prefix}`;
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name) || isPyprojectToml(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find((file) => {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return isRequirementsFile(name)
|
||||
? `${pip} -r ${shellQuote(file)}`
|
||||
: `${pip} ${shellQuote(path.dirname(file))}`;
|
||||
}
|
||||
return `${pip} -- ${shellQuote(name)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
};
|
||||
|
||||
let command = baseCommands[type];
|
||||
|
||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||
}
|
||||
|
||||
return `${command} ${name.trim()}`;
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
export function getUninstallCommand(
|
||||
type: DependenceTypes,
|
||||
name: string,
|
||||
): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find((file) => {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
} -y -r ${shellQuote(file)}`;
|
||||
}
|
||||
if (isPyprojectToml(name) || isGitHubUrl(name)) {
|
||||
// A source URL/project can install several distributions and has no safe
|
||||
// inverse. Removing its panel entry leaves installed packages intact.
|
||||
return `printf '%s\\n' ${shellQuote(
|
||||
'Remove installed Python distributions separately by package name.',
|
||||
)}`;
|
||||
}
|
||||
const packageName =
|
||||
name.match(
|
||||
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
|
||||
)?.[1] || name;
|
||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
} -y -- ${shellQuote(packageName)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk del'
|
||||
: maybeSudo('apt-get remove -y'),
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
@@ -726,75 +931,73 @@ export async function detectOS(): Promise<
|
||||
return osType;
|
||||
}
|
||||
|
||||
async function getCurrentMirrorDomain(
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const lines = fileContent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/https?:\/\/[^\/]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function replaceDomainInFile(
|
||||
filePath: string,
|
||||
oldDomainWithScheme: string,
|
||||
newDomainWithScheme: string,
|
||||
): Promise<void> {
|
||||
let fileContent = await fs.readFile(filePath, 'utf8');
|
||||
let updatedContent = fileContent.replace(
|
||||
new RegExp(oldDomainWithScheme, 'g'),
|
||||
newDomainWithScheme,
|
||||
);
|
||||
|
||||
if (!newDomainWithScheme.endsWith('/')) {
|
||||
newDomainWithScheme += '/';
|
||||
}
|
||||
|
||||
await writeFileWithLock(filePath, updatedContent);
|
||||
}
|
||||
|
||||
async function _updateLinuxMirror(
|
||||
osType: string,
|
||||
mirrorDomainWithScheme: string,
|
||||
mirror: string,
|
||||
): Promise<string> {
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
const defaults: Record<string, string> = {
|
||||
Debian: 'http://deb.debian.org/debian',
|
||||
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
|
||||
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
|
||||
};
|
||||
if (!defaults[osType])
|
||||
throw new Error('Unsupported OS type for updating mirrors.');
|
||||
const target = mirror || defaults[osType];
|
||||
normalizeMirror(target);
|
||||
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||
const update =
|
||||
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||
let file =
|
||||
osType === 'Alpine'
|
||||
? '/etc/apk/repositories'
|
||||
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(file, 'utf8');
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
if (osType !== 'Alpine') {
|
||||
try {
|
||||
file = '/etc/apt/sources.list';
|
||||
content = await fs.readFile(file, 'utf8');
|
||||
} catch (legacyError: any) {
|
||||
if (legacyError.code !== 'ENOENT') throw legacyError;
|
||||
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
|
||||
} else {
|
||||
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
|
||||
}
|
||||
default:
|
||||
throw Error('Unsupported OS type for updating mirrors.');
|
||||
}
|
||||
}
|
||||
if (!content.trim()) {
|
||||
const release = await fs.readFile('/etc/os-release', 'utf8');
|
||||
if (osType === 'Alpine') {
|
||||
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
|
||||
if (!version)
|
||||
throw new Error('Cannot determine Alpine repository version');
|
||||
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
|
||||
} else {
|
||||
// The fallback may have read an empty legacy .list. Deb822 stanzas must
|
||||
// be written to a .sources file, leaving the legacy file untouched.
|
||||
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
|
||||
if (!codename)
|
||||
throw new Error('Cannot determine APT repository codename');
|
||||
const ubuntu = osType === 'Ubuntu';
|
||||
content = `Types: deb\nURIs: ${
|
||||
defaults[osType]
|
||||
}\nSuites: ${codename} ${codename}-updates${
|
||||
ubuntu ? ` ${codename}-backports` : ''
|
||||
}\nComponents: ${
|
||||
ubuntu ? 'main restricted universe multiverse' : 'main'
|
||||
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
|
||||
if (!ubuntu)
|
||||
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
|
||||
}
|
||||
}
|
||||
return mirrorWriteCommand(
|
||||
file,
|
||||
rewriteMirrorSources(content, target),
|
||||
S,
|
||||
update,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
||||
|
||||
@@ -13,6 +13,7 @@ export class Crontab {
|
||||
pid?: number;
|
||||
isDisabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
queued_token?: string | null;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
last_running_time?: number;
|
||||
@@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
isDisabled: DataTypes.NUMBER,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
labels: DataTypes.JSON,
|
||||
last_running_time: DataTypes.NUMBER,
|
||||
last_execution_time: DataTypes.NUMBER,
|
||||
|
||||
+9
-1
@@ -21,6 +21,7 @@ export enum NotificationMode {
|
||||
'ntfy' = 'ntfy',
|
||||
'wxPusherBot' = 'wxPusherBot',
|
||||
'openiLink' = 'openiLink',
|
||||
'wpush' = 'wpush',
|
||||
}
|
||||
|
||||
abstract class NotificationBaseInfo {
|
||||
@@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo {
|
||||
public openiLinkContextToken = '';
|
||||
}
|
||||
|
||||
export class WpushNotification extends NotificationBaseInfo {
|
||||
public wpushApiKey = '';
|
||||
public wpushChannel = '';
|
||||
public wpushTopicCode = '';
|
||||
}
|
||||
|
||||
export interface NotificationInfo
|
||||
extends GoCqHttpBotNotification,
|
||||
GotifyNotification,
|
||||
@@ -195,4 +202,5 @@ export interface NotificationInfo
|
||||
NtfyNotification,
|
||||
WxPusherBotNotification,
|
||||
WxPusherSptNotification,
|
||||
OpeniLinkNotification {}
|
||||
OpeniLinkNotification,
|
||||
WpushNotification {}
|
||||
|
||||
@@ -11,8 +11,6 @@ export enum InstanceStatus {
|
||||
export interface RunningInstanceAttributes {
|
||||
id?: number;
|
||||
cron_id: number;
|
||||
run_id?: string | null;
|
||||
attempt_id?: string | null;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at: number;
|
||||
@@ -24,8 +22,6 @@ export interface RunningInstanceAttributes {
|
||||
export class RunningInstance {
|
||||
id?: number;
|
||||
cron_id!: number;
|
||||
run_id?: string | null;
|
||||
attempt_id?: string | null;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at!: number;
|
||||
@@ -36,8 +32,6 @@ export class RunningInstance {
|
||||
constructor(options: RunningInstanceAttributes) {
|
||||
this.id = options.id;
|
||||
this.cron_id = options.cron_id;
|
||||
this.run_id = options.run_id;
|
||||
this.attempt_id = options.attempt_id;
|
||||
this.pid = options.pid;
|
||||
this.log_path = options.log_path;
|
||||
this.started_at = options.started_at;
|
||||
@@ -58,15 +52,6 @@ export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
@@ -20,7 +20,7 @@ export class Subscription {
|
||||
pull_option?:
|
||||
| { private_key: string }
|
||||
| { username: string; password: string };
|
||||
pid?: number;
|
||||
pid?: number | null;
|
||||
is_disabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
alias: string;
|
||||
|
||||
@@ -28,8 +28,12 @@ export enum AuthDataType {
|
||||
'removeLogFrequency' = 'removeLogFrequency',
|
||||
'systemConfig' = 'systemConfig',
|
||||
'authConfig' = 'authConfig',
|
||||
'notifyLog' = 'notifyLog',
|
||||
}
|
||||
|
||||
export const NotifyStatus = LoginStatus;
|
||||
export type NotifyStatus = LoginStatus;
|
||||
|
||||
export interface SystemConfigInfo {
|
||||
lang?: string;
|
||||
panelTitle?: string;
|
||||
@@ -41,6 +45,8 @@ export interface SystemConfigInfo {
|
||||
linuxMirror?: string;
|
||||
timezone?: string;
|
||||
globalSshKey?: string;
|
||||
runningInstanceRetentionDays?: number;
|
||||
cronStatRetentionDays?: number;
|
||||
}
|
||||
|
||||
export interface LoginLogInfo {
|
||||
@@ -51,6 +57,17 @@ export interface LoginLogInfo {
|
||||
status?: LoginStatus;
|
||||
}
|
||||
|
||||
export interface NotifyLogInfo {
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: NotifyStatus;
|
||||
notifyType?: string;
|
||||
source?: 'system' | 'script';
|
||||
scriptName?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
value: string;
|
||||
timestamp: number;
|
||||
@@ -73,16 +90,20 @@ export interface AuthInfo {
|
||||
lastaddr: string;
|
||||
platform: string;
|
||||
isTwoFactorChecking: boolean;
|
||||
twoFactorExpiresAt?: number;
|
||||
lastTwoFactorStep?: number;
|
||||
token: string;
|
||||
tokens: Record<string, string | TokenInfo[]>;
|
||||
twoFactorActivated: boolean;
|
||||
twoFactorSecret: string;
|
||||
avatar: string;
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
Partial<NotificationInfo> &
|
||||
LoginLogInfo &
|
||||
Partial<NotifyLogInfo> &
|
||||
Partial<AuthInfo>;
|
||||
|
||||
export interface SystemInstance
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Application } from 'express';
|
||||
import linkDeps from './deps';
|
||||
import initTask from './initTask';
|
||||
import initFile from './initFile';
|
||||
import { initializeTrustProxy } from '../shared/trustProxy';
|
||||
|
||||
export default async ({ app }: { app: Application }) => {
|
||||
depInjectorLoader();
|
||||
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app });
|
||||
await initializeTrustProxy(app);
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+4
-2
@@ -8,7 +8,8 @@ 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 { sequelize } from '../data';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
@@ -21,7 +22,8 @@ export default async () => {
|
||||
await CrontabViewModel.sync();
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
await runMigrations();
|
||||
|
||||
await migrateSchema(sequelize);
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
|
||||
+45
-1
@@ -17,6 +17,50 @@ async function linkCommand() {
|
||||
}
|
||||
|
||||
async function linkCommandToDir(commandDir: string) {
|
||||
const cliRoot = process.env.QL_CLI_ROOT;
|
||||
if (cliRoot) {
|
||||
if (!path.isAbsolute(cliRoot)) {
|
||||
throw new Error('QL_CLI_ROOT must be an absolute CLI installation path');
|
||||
}
|
||||
const { installCliEntrypoints } = require(path.join(
|
||||
cliRoot,
|
||||
'dist/local/entrypoints.js',
|
||||
));
|
||||
await installCliEntrypoints(commandDir, cliRoot);
|
||||
const { installCronEntrypoint } = require(path.join(
|
||||
cliRoot,
|
||||
'dist/local/cronEntrypoint.js',
|
||||
));
|
||||
await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, {
|
||||
...process.env,
|
||||
QL_DIR: config.rootPath,
|
||||
QL_DATA_DIR: config.dataPath,
|
||||
});
|
||||
// Container-wide legacy links may precede ~/bin. Ensure panel children use
|
||||
// the explicitly selected entries without changing system-wide links.
|
||||
process.env.PATH = [
|
||||
commandDir,
|
||||
...(process.env.PATH?.split(path.delimiter) ?? []).filter(
|
||||
(entry) => entry !== commandDir,
|
||||
),
|
||||
].join(path.delimiter);
|
||||
return;
|
||||
}
|
||||
|
||||
const cronBridge = path.join(commandDir, 'crontab');
|
||||
try {
|
||||
if (
|
||||
(await fs.lstat(cronBridge)).isFile() &&
|
||||
(await fs.readFile(cronBridge, 'utf8')).includes(
|
||||
'// QingLong CLI crontab bridge',
|
||||
)
|
||||
) {
|
||||
await fs.unlink(cronBridge);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const linkShell = [
|
||||
{
|
||||
src: 'update.sh',
|
||||
@@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) {
|
||||
if (stats) {
|
||||
await fs.unlink(tmpTarget);
|
||||
}
|
||||
} catch (error) { }
|
||||
} catch (error) {}
|
||||
|
||||
await fs.symlink(source, tmpTarget);
|
||||
await fs.rename(tmpTarget, target);
|
||||
|
||||
+58
-28
@@ -6,7 +6,7 @@ import config from '../config';
|
||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||
import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import { errors } from 'celebrate';
|
||||
import validationErrors from '../middlewares/validationErrors';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
@@ -14,30 +14,33 @@ import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { AppScope } from '../data/open';
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
|
||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||
const setting = value?.trim();
|
||||
if (!setting) {
|
||||
return 'loopback';
|
||||
}
|
||||
if (setting === 'true' || setting === 'false') {
|
||||
return setting === 'true';
|
||||
}
|
||||
if (/^\d+$/.test(setting)) {
|
||||
return Number(setting);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
export default ({ app }: { app: Application }) => {
|
||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||
app.set('case sensitive routing', true);
|
||||
app.set('strict routing', true);
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.set('trust proxy', resolveTrustProxy());
|
||||
app.use(cors());
|
||||
|
||||
// Security: Path normalization middleware to prevent case variation attacks
|
||||
app.use((req, res, next) => {
|
||||
const originalPath = req.path;
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
// Block requests with case variations on protected paths
|
||||
if (originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
// Security: Reject case variations under protected API namespaces before
|
||||
// authentication checks can interpret the request differently from routing.
|
||||
app.use(protectedPathCase);
|
||||
|
||||
// Rewrite URLs to strip baseUrl prefix if configured
|
||||
// This allows the rest of the app to work without baseUrl awareness
|
||||
@@ -46,7 +49,26 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
||||
app.use(
|
||||
`${config.api.prefix}/static`,
|
||||
express.static(config.uploadPath, {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
|
||||
['/user/login', '/user/init', '/user/two-factor/login'].map(
|
||||
(route) => `${prefix}${route}`,
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
credentialPaths,
|
||||
bodyParser.json({ limit: '16kb' }),
|
||||
bodyParser.urlencoded({ limit: '16kb', extended: false }),
|
||||
);
|
||||
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
@@ -97,7 +119,10 @@ export default ({ app }: { app: Application }) => {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
|
||||
if (
|
||||
!currentToken ||
|
||||
currentToken.expiration < Math.round(Date.now() / 1000)
|
||||
) {
|
||||
const err = new UnauthorizedError('invalid_token', {
|
||||
message: t('Token 已失效'),
|
||||
});
|
||||
@@ -118,14 +143,12 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken
|
||||
? t('Token 已失效')
|
||||
: t('请先登录');
|
||||
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
});
|
||||
@@ -142,8 +165,7 @@ export default ({ app }: { app: Application }) => {
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
|
||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
|
||||
@@ -157,7 +179,14 @@ export default ({ app }: { app: Application }) => {
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
|
||||
app.get('*', (_, res, next) => {
|
||||
app.get('*', (req, res, next) => {
|
||||
// Don't serve index.html for API routes
|
||||
if (
|
||||
req.path === config.api.prefix ||
|
||||
req.path.startsWith(`${config.api.prefix}/`)
|
||||
) {
|
||||
return res.status(404).json({ code: 404, message: 'Not Found' });
|
||||
}
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
@@ -168,7 +197,8 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
app.use(validationErrors);
|
||||
app.use(uniqueConstraintError);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type express from 'express';
|
||||
import config from '../config';
|
||||
|
||||
export async function createHttpApplication(): Promise<express.Application> {
|
||||
// Only the HTTP worker needs an Express application and its middleware.
|
||||
const [
|
||||
{ default: createExpress },
|
||||
{ default: helmet },
|
||||
{ default: cors },
|
||||
{ default: compression },
|
||||
{ monitoringMiddleware },
|
||||
] = await Promise.all([
|
||||
import('express'),
|
||||
import('helmet'),
|
||||
import('cors'),
|
||||
import('compression'),
|
||||
import('../middlewares/monitoring'),
|
||||
]);
|
||||
const app = createExpress();
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.t) delete req.query.t;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
app.use(cors(config.cors));
|
||||
app.use(compression());
|
||||
app.use(monitoringMiddleware);
|
||||
return app;
|
||||
}
|
||||
+14
-26
@@ -13,11 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import SystemService from '../services/system';
|
||||
import UserService from '../services/user';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||
import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import { AppModel } from '../data/open';
|
||||
import cronClient from '../schedule/client';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
|
||||
@@ -30,24 +30,7 @@ export default async () => {
|
||||
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' } },
|
||||
);
|
||||
}
|
||||
await openService.initializeSystemApp();
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
@@ -143,7 +126,11 @@ export default async () => {
|
||||
|
||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped },
|
||||
{
|
||||
status: InstanceStatus.stopped,
|
||||
finished_at: Math.floor(Date.now() / 1000),
|
||||
// The previous process's exit code cannot be recovered after a restart.
|
||||
},
|
||||
{ where: { status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
@@ -236,14 +223,15 @@ export default async () => {
|
||||
} catch { }
|
||||
|
||||
// 初始化保存一次ck和定时任务数据
|
||||
await cronService.autosave_crontab();
|
||||
cronClient.readiness.configure(async () => {
|
||||
await cronService.autosave_crontab(true);
|
||||
await systemService.restoreCronConcurrency();
|
||||
});
|
||||
await cronClient.readiness.recover();
|
||||
|
||||
await envService.set_envs();
|
||||
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
const apps = await openService.findApps();
|
||||
await shareStore.updateAuthInfo(authInfo);
|
||||
if (apps?.length) {
|
||||
await shareStore.updateApps(apps);
|
||||
}
|
||||
await openService.refreshApps();
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import config from '../config';
|
||||
import { fileExist } from '../config/util';
|
||||
import { join } from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import Logger from './logger';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
@@ -24,6 +26,7 @@ export default async () => {
|
||||
if (await fileExist(tokenFile)) {
|
||||
tokenCommand = `node ${tokenFile}`;
|
||||
}
|
||||
tokenCommand += ' --renew';
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: t('生成token'),
|
||||
@@ -60,7 +63,7 @@ export default async () => {
|
||||
}
|
||||
|
||||
systemService.updateTimezone(data.info);
|
||||
|
||||
|
||||
// Apply global SSH key if configured
|
||||
if (data.info.globalSshKey) {
|
||||
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||
@@ -70,6 +73,12 @@ export default async () => {
|
||||
await subscriptionService.setSshConfig();
|
||||
const subs = await subscriptionService.list();
|
||||
for (const sub of subs) {
|
||||
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
|
||||
try {
|
||||
const doc = sub.get({ plain: true });
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||
} catch (error) {
|
||||
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+66
-8
@@ -1,4 +1,4 @@
|
||||
import sockJs from 'sockjs';
|
||||
import sockJs, { Connection } from 'sockjs';
|
||||
import { Server } from 'http';
|
||||
import { Container } from 'typedi';
|
||||
import SockService from '../services/sock';
|
||||
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
|
||||
const echo = sockJs.createServer({
|
||||
prefix: `${config.baseUrl}/api/ws`,
|
||||
log: () => {},
|
||||
});
|
||||
const sockService = Container.get(SockService);
|
||||
const sessions = new Map<Connection, { token: string; platform: string }>();
|
||||
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let checking = false;
|
||||
|
||||
const checkSessions = async () => {
|
||||
if (checking || sessions.size === 0) return;
|
||||
checking = true;
|
||||
// Connections accepted during this read must not use an older snapshot.
|
||||
const batch = [...sessions];
|
||||
try {
|
||||
const current = await shareStore.getAuthInfo();
|
||||
// Reuse validation only within this synchronous check of one auth snapshot.
|
||||
const validated = new Map<string, Map<string, boolean>>();
|
||||
for (const [conn, { token, platform }] of batch) {
|
||||
if (!sessions.has(conn)) continue;
|
||||
let platforms = validated.get(token);
|
||||
if (!platforms) {
|
||||
platforms = new Map();
|
||||
validated.set(token, platforms);
|
||||
}
|
||||
let valid = platforms.get(platform);
|
||||
if (valid === undefined) {
|
||||
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||
platforms.set(platform, valid);
|
||||
}
|
||||
if (!valid) conn.close('401');
|
||||
}
|
||||
} catch {
|
||||
for (const [conn] of batch) {
|
||||
if (sessions.has(conn)) conn.close('401');
|
||||
}
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
|
||||
echo.on('connection', async (conn) => {
|
||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||
conn.close('404');
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
let closed = false;
|
||||
conn.on('close', () => {
|
||||
closed = true;
|
||||
sessions.delete(conn);
|
||||
sockService.removeClient(conn);
|
||||
if (sessions.size === 0 && sessionTimer) {
|
||||
clearInterval(sessionTimer);
|
||||
sessionTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
let authInfo;
|
||||
try {
|
||||
authInfo = await shareStore.getAuthInfo();
|
||||
} catch {
|
||||
if (!closed) conn.close('401');
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform)) {
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
sessionTimer = setInterval(checkSessions, 1000);
|
||||
sessionTimer.unref();
|
||||
}
|
||||
|
||||
conn.on('data', (message) => {
|
||||
conn.write(message);
|
||||
});
|
||||
|
||||
conn.on('close', function () {
|
||||
sockService.removeClient(conn);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,46 +3,34 @@ import Logger from '../loaders/logger';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { metricsService } from '../services/metrics';
|
||||
|
||||
interface RequestMetrics {
|
||||
method: string;
|
||||
path: string;
|
||||
duration: number;
|
||||
statusCode: number;
|
||||
timestamp: number;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
const requestMetrics: RequestMetrics[] = [];
|
||||
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
|
||||
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
|
||||
let requestSampleOffset = 0;
|
||||
|
||||
export const monitoringMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const originalEnd = res.end;
|
||||
|
||||
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
||||
const duration = performance.now() - start;
|
||||
const metric: RequestMetrics = {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
duration,
|
||||
statusCode: res.statusCode,
|
||||
timestamp: Date.now(),
|
||||
platform: req.platform,
|
||||
};
|
||||
|
||||
requestMetrics.push(metric);
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
|
||||
if (requestMetrics.length > 1000) {
|
||||
requestMetrics.shift();
|
||||
const shouldSample = requestSampleOffset === 0;
|
||||
requestSampleOffset =
|
||||
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
|
||||
if (shouldSample) {
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const getMetrics = () => {
|
||||
return {
|
||||
totalRequests: requestMetrics.length,
|
||||
averageDuration:
|
||||
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
|
||||
requestMetrics.length,
|
||||
requestsByMethod: requestMetrics.reduce((acc, curr) => {
|
||||
acc[curr.method] = (acc[curr.method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
|
||||
if (curr.platform) {
|
||||
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
recentRequests: requestMetrics.slice(-10),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export default function protectedPathCase(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const originalPath = req.path;
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
if (
|
||||
originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') ||
|
||||
normalizedPath.startsWith('/open/'))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format',
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { UniqueConstraintError } from 'sequelize';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
// Handle the database constraint itself so concurrent writes are covered too.
|
||||
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
if (!(err instanceof UniqueConstraintError)) return next(err);
|
||||
// Constraint details can contain environment values or application credentials.
|
||||
res
|
||||
.status(409)
|
||||
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
|
||||
};
|
||||
|
||||
export default uniqueConstraintError;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { errors, isCelebrateError } from 'celebrate';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
const respond = errors();
|
||||
|
||||
// Schemas are compiled once at startup; resolve their messages per request so
|
||||
// changing the system language also changes errors from those existing schemas.
|
||||
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
|
||||
if (isCelebrateError(error)) {
|
||||
for (const detail of error.details.values()) {
|
||||
detail.message = detail.details.map((item) => t(item.message)).join('. ');
|
||||
}
|
||||
}
|
||||
return respond(error, req, res, next);
|
||||
};
|
||||
|
||||
export default validationErrors;
|
||||
@@ -1,84 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
type ColumnType = 'string' | 'integer' | 'text' | 'json';
|
||||
|
||||
interface LegacyColumn {
|
||||
table: string;
|
||||
column: string;
|
||||
type: ColumnType;
|
||||
}
|
||||
|
||||
const columns: LegacyColumn[] = [
|
||||
{ table: 'CrontabViews', column: 'filterRelation', type: 'string' },
|
||||
{ table: 'Subscriptions', column: 'proxy', type: 'string' },
|
||||
{ table: 'CrontabViews', column: 'type', type: 'integer' },
|
||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'integer' },
|
||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'integer' },
|
||||
{ table: 'Crontabs', column: 'sub_id', type: 'integer' },
|
||||
{ table: 'Crontabs', column: 'extra_schedules', type: 'json' },
|
||||
{ table: 'Crontabs', column: 'task_before', type: 'text' },
|
||||
{ table: 'Crontabs', column: 'task_after', type: 'text' },
|
||||
{ table: 'Crontabs', column: 'log_name', type: 'string' },
|
||||
{
|
||||
table: 'Crontabs',
|
||||
column: 'allow_multiple_instances',
|
||||
type: 'integer',
|
||||
},
|
||||
{ table: 'Crontabs', column: 'work_dir', type: 'string' },
|
||||
{ table: 'Envs', column: 'isPinned', type: 'integer' },
|
||||
{ table: 'Envs', column: 'labels', type: 'json' },
|
||||
];
|
||||
|
||||
export const legacyColumnOwnership: readonly {
|
||||
table: string;
|
||||
column: string;
|
||||
type: ColumnType;
|
||||
}[] = columns;
|
||||
|
||||
function dataType(type: ColumnType) {
|
||||
switch (type) {
|
||||
case 'integer':
|
||||
return DataTypes.INTEGER;
|
||||
case 'text':
|
||||
return DataTypes.TEXT;
|
||||
case 'json':
|
||||
return DataTypes.JSON;
|
||||
default:
|
||||
return DataTypes.STRING;
|
||||
}
|
||||
}
|
||||
|
||||
export const legacyColumnsMigration: Migration = {
|
||||
id: '0001-legacy-columns',
|
||||
checksum: createHash('sha256').update(JSON.stringify(columns)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
const descriptions = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const definition of columns) {
|
||||
let description = descriptions.get(definition.table);
|
||||
if (!description) {
|
||||
description = await queryInterface.describeTable(definition.table);
|
||||
descriptions.set(definition.table, description);
|
||||
}
|
||||
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(description, definition.column)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryInterface.addColumn(
|
||||
definition.table,
|
||||
definition.column,
|
||||
{
|
||||
type: dataType(definition.type),
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
description[definition.column] = {};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,319 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_TABLE = 'Runs';
|
||||
export const RUN_ATTEMPT_TABLE = 'RunAttempts';
|
||||
export const RUN_EVENT_TABLE = 'RunEvents';
|
||||
|
||||
const schemaManifest = {
|
||||
version: 1,
|
||||
tables: {
|
||||
Runs: [
|
||||
'id',
|
||||
'project_id',
|
||||
'task_id',
|
||||
'task_revision',
|
||||
'task_name',
|
||||
'task_snapshot_ref',
|
||||
'legacy_cron_id',
|
||||
'parent_run_id',
|
||||
'retry_of_run_id',
|
||||
'trigger_id',
|
||||
'trigger_type',
|
||||
'execution_origin',
|
||||
'execution_owner',
|
||||
'triggered_by',
|
||||
'request_id',
|
||||
'scheduled_for_ms',
|
||||
'status',
|
||||
'version',
|
||||
'event_sequence',
|
||||
'priority',
|
||||
'idempotency_key',
|
||||
'input_ref',
|
||||
'output_ref',
|
||||
'created_at_ms',
|
||||
'queued_at_ms',
|
||||
'started_at_ms',
|
||||
'finished_at_ms',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
],
|
||||
RunAttempts: [
|
||||
'id',
|
||||
'run_id',
|
||||
'step_run_id',
|
||||
'attempt',
|
||||
'status',
|
||||
'executor_type',
|
||||
'worker_id',
|
||||
'executor_handle',
|
||||
'pid',
|
||||
'log_artifact_id',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'callback_token_hash',
|
||||
'callback_sequence',
|
||||
'created_at_ms',
|
||||
'started_at_ms',
|
||||
'finished_at_ms',
|
||||
'exit_code',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
],
|
||||
RunEvents: [
|
||||
'id',
|
||||
'run_id',
|
||||
'sequence',
|
||||
'type',
|
||||
'dedupe_key',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'attempt_id',
|
||||
'step_run_id',
|
||||
'payload',
|
||||
'created_at_ms',
|
||||
],
|
||||
},
|
||||
indexes: [
|
||||
'runs_project_created_idx',
|
||||
'runs_task_created_idx',
|
||||
'runs_status_queued_idx',
|
||||
'runs_legacy_cron_created_idx',
|
||||
'runs_project_idempotency_uidx',
|
||||
'run_attempts_run_attempt_uidx',
|
||||
'run_attempts_run_status_idx',
|
||||
'run_attempts_status_created_idx',
|
||||
'run_attempts_lease_idx',
|
||||
'run_events_run_sequence_uidx',
|
||||
'run_events_run_dedupe_uidx',
|
||||
'run_events_run_created_idx',
|
||||
],
|
||||
constraints: [
|
||||
'runs_version_nonnegative_check',
|
||||
'runs_event_sequence_nonnegative_check',
|
||||
'run_attempts_attempt_positive_check',
|
||||
'run_attempts_callback_sequence_nonnegative_check',
|
||||
'run_events_sequence_positive_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const runSchemaManifest = schemaManifest;
|
||||
|
||||
export const runSchemaMigration: Migration = {
|
||||
id: '0002-run-schema',
|
||||
checksum: createHash('sha256')
|
||||
.update(JSON.stringify(schemaManifest))
|
||||
.digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
RUN_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
||||
project_id: { type: DataTypes.STRING(128), allowNull: false },
|
||||
task_id: { type: DataTypes.STRING(255), allowNull: false },
|
||||
task_revision: { type: DataTypes.STRING(128), allowNull: false },
|
||||
task_name: { type: DataTypes.STRING(255), allowNull: true },
|
||||
task_snapshot_ref: { type: DataTypes.STRING(512), allowNull: true },
|
||||
legacy_cron_id: { type: DataTypes.INTEGER, allowNull: true },
|
||||
parent_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
retry_of_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
trigger_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
trigger_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
execution_origin: { type: DataTypes.STRING(64), allowNull: false },
|
||||
execution_owner: { type: DataTypes.STRING(16), allowNull: false },
|
||||
triggered_by: { type: DataTypes.STRING(255), allowNull: true },
|
||||
request_id: { type: DataTypes.STRING(128), allowNull: true },
|
||||
scheduled_for_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
event_sequence: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
idempotency_key: { type: DataTypes.STRING(255), allowNull: true },
|
||||
input_ref: { type: DataTypes.STRING(512), allowNull: true },
|
||||
output_ref: { type: DataTypes.STRING(512), allowNull: true },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
queued_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
error_code: { type: DataTypes.STRING(128), allowNull: true },
|
||||
error_summary: { type: DataTypes.STRING(1024), allowNull: true },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await queryInterface.createTable(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
step_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
attempt: { type: DataTypes.INTEGER, allowNull: false },
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
executor_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
worker_id: { type: DataTypes.STRING(128), allowNull: true },
|
||||
executor_handle: { type: DataTypes.TEXT, allowNull: true },
|
||||
pid: { type: DataTypes.INTEGER, allowNull: true },
|
||||
log_artifact_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
callback_token_hash: { type: DataTypes.STRING(128), allowNull: true },
|
||||
callback_sequence: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
exit_code: { type: DataTypes.INTEGER, allowNull: true },
|
||||
error_code: { type: DataTypes.STRING(128), allowNull: true },
|
||||
error_summary: { type: DataTypes.STRING(1024), allowNull: true },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await queryInterface.createTable(
|
||||
RUN_EVENT_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), allowNull: false, primaryKey: true },
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
||||
type: { type: DataTypes.STRING(128), allowNull: false },
|
||||
dedupe_key: { type: DataTypes.STRING(255), allowNull: true },
|
||||
actor_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
actor_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
attempt_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: true,
|
||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
step_run_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
payload: { type: DataTypes.JSON, allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await queryInterface.addConstraint(RUN_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 0 } },
|
||||
name: 'runs_version_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_TABLE, {
|
||||
fields: ['event_sequence'],
|
||||
type: 'check',
|
||||
where: { event_sequence: { [Op.gte]: 0 } },
|
||||
name: 'runs_event_sequence_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_ATTEMPT_TABLE, {
|
||||
fields: ['attempt'],
|
||||
type: 'check',
|
||||
where: { attempt: { [Op.gte]: 1 } },
|
||||
name: 'run_attempts_attempt_positive_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_ATTEMPT_TABLE, {
|
||||
fields: ['callback_sequence'],
|
||||
type: 'check',
|
||||
where: { callback_sequence: { [Op.gte]: 0 } },
|
||||
name: 'run_attempts_callback_sequence_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_EVENT_TABLE, {
|
||||
fields: ['sequence'],
|
||||
type: 'check',
|
||||
where: { sequence: { [Op.gte]: 1 } },
|
||||
name: 'run_events_sequence_positive_check',
|
||||
transaction,
|
||||
});
|
||||
|
||||
await queryInterface.addIndex(RUN_TABLE, ['project_id', 'created_at_ms'], {
|
||||
name: 'runs_project_created_idx',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(RUN_TABLE, ['task_id', 'created_at_ms'], {
|
||||
name: 'runs_task_created_idx',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(RUN_TABLE, ['status', 'queued_at_ms'], {
|
||||
name: 'runs_status_queued_idx',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_TABLE,
|
||||
['legacy_cron_id', 'created_at_ms'],
|
||||
{ name: 'runs_legacy_cron_created_idx', transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
RUN_TABLE,
|
||||
['project_id', 'idempotency_key'],
|
||||
{
|
||||
name: 'runs_project_idempotency_uidx',
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['run_id', 'attempt'], {
|
||||
name: 'run_attempts_run_attempt_uidx',
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['run_id', 'status'], {
|
||||
name: 'run_attempts_run_status_idx',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
['status', 'created_at_ms'],
|
||||
{ name: 'run_attempts_status_created_idx', transaction },
|
||||
);
|
||||
await queryInterface.addIndex(RUN_ATTEMPT_TABLE, ['lease_expires_at_ms'], {
|
||||
name: 'run_attempts_lease_idx',
|
||||
transaction,
|
||||
});
|
||||
|
||||
await queryInterface.addIndex(RUN_EVENT_TABLE, ['run_id', 'sequence'], {
|
||||
name: 'run_events_run_sequence_uidx',
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(RUN_EVENT_TABLE, ['run_id', 'dedupe_key'], {
|
||||
name: 'run_events_run_dedupe_uidx',
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_EVENT_TABLE,
|
||||
['run_id', 'created_at_ms'],
|
||||
{ name: 'run_events_run_created_idx', transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUNNING_INSTANCE_TABLE = 'RunningInstances';
|
||||
export const RUNNING_INSTANCE_RUN_INDEX = 'running_instances_run_started_idx';
|
||||
export const RUNNING_INSTANCE_ATTEMPT_INDEX = 'running_instances_attempt_uidx';
|
||||
|
||||
const manifest = {
|
||||
table: RUNNING_INSTANCE_TABLE,
|
||||
columns: {
|
||||
run_id: 'varchar(36) null',
|
||||
attempt_id: 'varchar(36) null',
|
||||
},
|
||||
indexes: [
|
||||
`${RUNNING_INSTANCE_RUN_INDEX}(run_id,started_at)`,
|
||||
`${RUNNING_INSTANCE_ATTEMPT_INDEX}(attempt_id) unique`,
|
||||
],
|
||||
};
|
||||
|
||||
export const runningInstanceRunReferenceManifest = manifest;
|
||||
|
||||
export const runningInstanceRunReferenceMigration: Migration = {
|
||||
id: '0003-running-instance-run-reference',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
const tables = new Set(await queryInterface.showAllTables());
|
||||
if (!tables.has(RUNNING_INSTANCE_TABLE)) return;
|
||||
|
||||
const columns = await queryInterface.describeTable(RUNNING_INSTANCE_TABLE);
|
||||
if (!columns.run_id) {
|
||||
await queryInterface.addColumn(
|
||||
RUNNING_INSTANCE_TABLE,
|
||||
'run_id',
|
||||
{ type: DataTypes.STRING(36), allowNull: true },
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
if (!columns.attempt_id) {
|
||||
await queryInterface.addColumn(
|
||||
RUNNING_INSTANCE_TABLE,
|
||||
'attempt_id',
|
||||
{ type: DataTypes.STRING(36), allowNull: true },
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndexes = (await queryInterface.showIndex(
|
||||
RUNNING_INSTANCE_TABLE,
|
||||
{ transaction },
|
||||
)) as Array<{ name: string }>;
|
||||
const indexes = new Set(currentIndexes.map((index) => index.name));
|
||||
if (!indexes.has(RUNNING_INSTANCE_RUN_INDEX)) {
|
||||
await queryInterface.addIndex(
|
||||
RUNNING_INSTANCE_TABLE,
|
||||
['run_id', 'started_at'],
|
||||
{
|
||||
name: RUNNING_INSTANCE_RUN_INDEX,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!indexes.has(RUNNING_INSTANCE_ATTEMPT_INDEX)) {
|
||||
await queryInterface.addIndex(RUNNING_INSTANCE_TABLE, ['attempt_id'], {
|
||||
name: RUNNING_INSTANCE_ATTEMPT_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { RUN_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_CANCELLATION_REQUEST_INDEX =
|
||||
'runs_status_cancel_requested_idx';
|
||||
|
||||
const manifest = {
|
||||
table: RUN_TABLE,
|
||||
columns: {
|
||||
cancel_requested_at_ms: 'bigint null',
|
||||
cancel_reason: 'varchar(32) null',
|
||||
},
|
||||
indexes: [`${RUN_CANCELLATION_REQUEST_INDEX}(status,cancel_requested_at_ms)`],
|
||||
};
|
||||
|
||||
export const runCancellationRequestManifest = manifest;
|
||||
|
||||
export const runCancellationRequestMigration: Migration = {
|
||||
id: '0004-run-cancellation-request',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
const tables = new Set(await queryInterface.showAllTables());
|
||||
if (!tables.has(RUN_TABLE)) return;
|
||||
|
||||
const columns = await queryInterface.describeTable(RUN_TABLE);
|
||||
if (!columns.cancel_requested_at_ms) {
|
||||
await queryInterface.addColumn(
|
||||
RUN_TABLE,
|
||||
'cancel_requested_at_ms',
|
||||
{ type: DataTypes.BIGINT, allowNull: true },
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
if (!columns.cancel_reason) {
|
||||
await queryInterface.addColumn(
|
||||
RUN_TABLE,
|
||||
'cancel_reason',
|
||||
{ type: DataTypes.STRING(32), allowNull: true },
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndexes = (await queryInterface.showIndex(RUN_TABLE, {
|
||||
transaction,
|
||||
})) as Array<{ name: string }>;
|
||||
if (
|
||||
!currentIndexes.some(
|
||||
(index) => index.name === RUN_CANCELLATION_REQUEST_INDEX,
|
||||
)
|
||||
) {
|
||||
await queryInterface.addIndex(
|
||||
RUN_TABLE,
|
||||
['status', 'cancel_requested_at_ms'],
|
||||
{ name: RUN_CANCELLATION_REQUEST_INDEX, transaction },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_CANCELLATION_DISPATCH_TABLE = 'RunCancellationDispatches';
|
||||
export const RUN_CANCELLATION_DISPATCH_DUE_INDEX =
|
||||
'run_cancel_dispatch_due_idx';
|
||||
export const RUN_CANCELLATION_DISPATCH_LEASE_INDEX =
|
||||
'run_cancel_dispatch_lease_idx';
|
||||
|
||||
const manifest = {
|
||||
table: RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
columns: [
|
||||
'run_id',
|
||||
'attempt_id',
|
||||
'status',
|
||||
'version',
|
||||
'dispatch_count',
|
||||
'next_attempt_at_ms',
|
||||
'lease_owner',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'last_result',
|
||||
'last_dispatched_at_ms',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${RUN_CANCELLATION_DISPATCH_DUE_INDEX}(status,next_attempt_at_ms)`,
|
||||
`${RUN_CANCELLATION_DISPATCH_LEASE_INDEX}(lease_expires_at_ms)`,
|
||||
],
|
||||
constraints: [
|
||||
'run_cancel_dispatch_version_nonnegative_check',
|
||||
'run_cancel_dispatch_count_nonnegative_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const runCancellationDispatchManifest = manifest;
|
||||
|
||||
export const runCancellationDispatchMigration: Migration = {
|
||||
id: '0005-run-cancellation-dispatch',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
{
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
attempt_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
dispatch_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
last_result: { type: DataTypes.STRING(64), allowNull: true },
|
||||
last_dispatched_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(RUN_CANCELLATION_DISPATCH_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 0 } },
|
||||
name: 'run_cancel_dispatch_version_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_CANCELLATION_DISPATCH_TABLE, {
|
||||
fields: ['dispatch_count'],
|
||||
type: 'check',
|
||||
where: { dispatch_count: { [Op.gte]: 0 } },
|
||||
name: 'run_cancel_dispatch_count_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
['status', 'next_attempt_at_ms'],
|
||||
{ name: RUN_CANCELLATION_DISPATCH_DUE_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
['lease_expires_at_ms'],
|
||||
{ name: RUN_CANCELLATION_DISPATCH_LEASE_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_ATTEMPT_DEADLINE_INDEX = 'run_attempt_status_deadline_idx';
|
||||
|
||||
const manifest = {
|
||||
table: RUN_ATTEMPT_TABLE,
|
||||
columns: {
|
||||
deadline_at_ms: 'bigint null',
|
||||
},
|
||||
indexes: [`${RUN_ATTEMPT_DEADLINE_INDEX}(status,deadline_at_ms,id)`],
|
||||
};
|
||||
|
||||
export const runAttemptDeadlineManifest = manifest;
|
||||
|
||||
export const runAttemptDeadlineMigration: Migration = {
|
||||
id: '0006-run-attempt-deadline',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
const tables = new Set(await queryInterface.showAllTables());
|
||||
if (!tables.has(RUN_ATTEMPT_TABLE)) return;
|
||||
|
||||
const columns = await queryInterface.describeTable(RUN_ATTEMPT_TABLE);
|
||||
if (!columns.deadline_at_ms) {
|
||||
await queryInterface.addColumn(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
'deadline_at_ms',
|
||||
{ type: DataTypes.BIGINT, allowNull: true },
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndexes = (await queryInterface.showIndex(RUN_ATTEMPT_TABLE, {
|
||||
transaction,
|
||||
})) as Array<{ name: string }>;
|
||||
if (
|
||||
!currentIndexes.some((index) => index.name === RUN_ATTEMPT_DEADLINE_INDEX)
|
||||
) {
|
||||
await queryInterface.addIndex(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
['status', 'deadline_at_ms', 'id'],
|
||||
{ name: RUN_ATTEMPT_DEADLINE_INDEX, transaction },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const COMPLETION_RECEIPT_JOURNAL_TABLE = 'CompletionReceiptJournals';
|
||||
export const COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX =
|
||||
'completion_receipt_journal_scan_idx';
|
||||
export const COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX =
|
||||
'completion_receipt_journal_purge_idx';
|
||||
|
||||
const manifest = {
|
||||
table: COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
columns: [
|
||||
'attempt_id',
|
||||
'run_id',
|
||||
'state',
|
||||
'quarantine_ref',
|
||||
'purge_after_ms',
|
||||
'registered_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX}(state,updated_at_ms,attempt_id)`,
|
||||
`${COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX}(state,purge_after_ms,attempt_id)`,
|
||||
],
|
||||
constraints: [
|
||||
'completion_receipt_journal_state_check',
|
||||
'completion_receipt_journal_registered_nonnegative_check',
|
||||
'completion_receipt_journal_updated_nonnegative_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const completionReceiptJournalManifest = manifest;
|
||||
|
||||
export const completionReceiptJournalMigration: Migration = {
|
||||
id: '0007-completion-receipt-journal',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
{
|
||||
attempt_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.STRING(16),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
quarantine_ref: { type: DataTypes.STRING(255), allowNull: true },
|
||||
purge_after_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
registered_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
||||
fields: ['state'],
|
||||
type: 'check',
|
||||
where: { state: { [Op.in]: ['pending', 'quarantined'] } },
|
||||
name: 'completion_receipt_journal_state_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
||||
fields: ['registered_at_ms'],
|
||||
type: 'check',
|
||||
where: { registered_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'completion_receipt_journal_registered_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(COMPLETION_RECEIPT_JOURNAL_TABLE, {
|
||||
fields: ['updated_at_ms'],
|
||||
type: 'check',
|
||||
where: { updated_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'completion_receipt_journal_updated_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
['state', 'updated_at_ms', 'attempt_id'],
|
||||
{ name: COMPLETION_RECEIPT_JOURNAL_SCAN_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
['state', 'purge_after_ms', 'attempt_id'],
|
||||
{ name: COMPLETION_RECEIPT_JOURNAL_PURGE_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const WORKER_REGISTRY_TABLE = 'Workers';
|
||||
export const WORKER_REGISTRY_LEASE_INDEX = 'workers_lease_idx';
|
||||
export const WORKER_REGISTRY_CAPACITY_INDEX = 'workers_capacity_idx';
|
||||
|
||||
const manifest = {
|
||||
table: WORKER_REGISTRY_TABLE,
|
||||
columns: [
|
||||
'id',
|
||||
'session_id',
|
||||
'generation',
|
||||
'status',
|
||||
'version',
|
||||
'capabilities_json',
|
||||
'capabilities_hash',
|
||||
'max_concurrent_runs',
|
||||
'available_slots',
|
||||
'registered_at_ms',
|
||||
'last_heartbeat_at_ms',
|
||||
'lease_expires_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${WORKER_REGISTRY_LEASE_INDEX}(status,lease_expires_at_ms,id)`,
|
||||
`${WORKER_REGISTRY_CAPACITY_INDEX}(status,available_slots,lease_expires_at_ms,id)`,
|
||||
],
|
||||
constraints: [
|
||||
'workers_generation_positive_check',
|
||||
'workers_version_nonnegative_check',
|
||||
'workers_max_concurrency_positive_check',
|
||||
'workers_available_slots_nonnegative_check',
|
||||
'workers_status_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const workerRegistryManifest = manifest;
|
||||
|
||||
export const workerRegistryMigration: Migration = {
|
||||
id: '0008-worker-registry',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
WORKER_REGISTRY_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
||||
session_id: { type: DataTypes.STRING(36), allowNull: false },
|
||||
generation: { type: DataTypes.INTEGER, allowNull: false },
|
||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
capabilities_json: { type: DataTypes.TEXT, allowNull: false },
|
||||
capabilities_hash: { type: DataTypes.STRING(64), allowNull: false },
|
||||
max_concurrent_runs: { type: DataTypes.INTEGER, allowNull: false },
|
||||
available_slots: { type: DataTypes.INTEGER, allowNull: false },
|
||||
registered_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
last_heartbeat_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
||||
fields: ['generation'],
|
||||
type: 'check',
|
||||
where: { generation: { [Op.gt]: 0 } },
|
||||
name: 'workers_generation_positive_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 0 } },
|
||||
name: 'workers_version_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
||||
fields: ['max_concurrent_runs'],
|
||||
type: 'check',
|
||||
where: { max_concurrent_runs: { [Op.gt]: 0 } },
|
||||
name: 'workers_max_concurrency_positive_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
||||
fields: ['available_slots'],
|
||||
type: 'check',
|
||||
where: { available_slots: { [Op.gte]: 0 } },
|
||||
name: 'workers_available_slots_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(WORKER_REGISTRY_TABLE, {
|
||||
fields: ['status'],
|
||||
type: 'check',
|
||||
where: { status: { [Op.in]: ['online', 'draining', 'offline'] } },
|
||||
name: 'workers_status_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
WORKER_REGISTRY_TABLE,
|
||||
['status', 'lease_expires_at_ms', 'id'],
|
||||
{ name: WORKER_REGISTRY_LEASE_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
WORKER_REGISTRY_TABLE,
|
||||
['status', 'available_slots', 'lease_expires_at_ms', 'id'],
|
||||
{ name: WORKER_REGISTRY_CAPACITY_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
||||
import { WORKER_REGISTRY_TABLE } from './0008-worker-registry';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_DISPATCH_LEASE_TABLE = 'RunDispatchLeases';
|
||||
export const RUN_DISPATCH_LEASE_EXPIRY_INDEX = 'run_dispatch_leases_expiry_idx';
|
||||
export const RUN_DISPATCH_LEASE_WORKER_INDEX = 'run_dispatch_leases_worker_idx';
|
||||
export const RUN_DISPATCH_LEASE_TOKEN_INDEX = 'run_dispatch_leases_token_uidx';
|
||||
|
||||
const manifest = {
|
||||
table: RUN_DISPATCH_LEASE_TABLE,
|
||||
columns: [
|
||||
'attempt_id',
|
||||
'run_id',
|
||||
'status',
|
||||
'version',
|
||||
'lease_generation',
|
||||
'worker_id',
|
||||
'worker_session_id',
|
||||
'worker_generation',
|
||||
'lease_token',
|
||||
'acquired_at_ms',
|
||||
'renewed_at_ms',
|
||||
'expires_at_ms',
|
||||
'released_at_ms',
|
||||
'release_reason',
|
||||
'completed_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${RUN_DISPATCH_LEASE_EXPIRY_INDEX}(status,expires_at_ms,attempt_id)`,
|
||||
`${RUN_DISPATCH_LEASE_WORKER_INDEX}(worker_id,worker_session_id,worker_generation,status,expires_at_ms,attempt_id)`,
|
||||
`${RUN_DISPATCH_LEASE_TOKEN_INDEX}(lease_token)`,
|
||||
],
|
||||
constraints: [
|
||||
'run_dispatch_leases_status_check',
|
||||
'run_dispatch_leases_version_nonnegative_check',
|
||||
'run_dispatch_leases_generation_positive_check',
|
||||
'run_dispatch_leases_worker_generation_positive_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const runDispatchLeaseManifest = manifest;
|
||||
|
||||
export const runDispatchLeaseMigration: Migration = {
|
||||
id: '0009-run-dispatch-lease',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
RUN_DISPATCH_LEASE_TABLE,
|
||||
{
|
||||
attempt_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
lease_generation: { type: DataTypes.INTEGER, allowNull: false },
|
||||
worker_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: WORKER_REGISTRY_TABLE, key: 'id' },
|
||||
onDelete: 'RESTRICT',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
worker_session_id: { type: DataTypes.STRING(36), allowNull: false },
|
||||
worker_generation: { type: DataTypes.INTEGER, allowNull: false },
|
||||
lease_token: { type: DataTypes.STRING(128), allowNull: false },
|
||||
acquired_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
renewed_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
released_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
release_reason: { type: DataTypes.STRING(32), allowNull: true },
|
||||
completed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
||||
fields: ['status'],
|
||||
type: 'check',
|
||||
where: { status: { [Op.in]: ['leased', 'released', 'completed'] } },
|
||||
name: 'run_dispatch_leases_status_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 0 } },
|
||||
name: 'run_dispatch_leases_version_nonnegative_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
||||
fields: ['lease_generation'],
|
||||
type: 'check',
|
||||
where: { lease_generation: { [Op.gt]: 0 } },
|
||||
name: 'run_dispatch_leases_generation_positive_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_DISPATCH_LEASE_TABLE, {
|
||||
fields: ['worker_generation'],
|
||||
type: 'check',
|
||||
where: { worker_generation: { [Op.gt]: 0 } },
|
||||
name: 'run_dispatch_leases_worker_generation_positive_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_DISPATCH_LEASE_TABLE,
|
||||
['status', 'expires_at_ms', 'attempt_id'],
|
||||
{ name: RUN_DISPATCH_LEASE_EXPIRY_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
RUN_DISPATCH_LEASE_TABLE,
|
||||
[
|
||||
'worker_id',
|
||||
'worker_session_id',
|
||||
'worker_generation',
|
||||
'status',
|
||||
'expires_at_ms',
|
||||
'attempt_id',
|
||||
],
|
||||
{ name: RUN_DISPATCH_LEASE_WORKER_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(RUN_DISPATCH_LEASE_TABLE, ['lease_token'], {
|
||||
name: RUN_DISPATCH_LEASE_TOKEN_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Op } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE, RUN_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_DISPATCH_CANDIDATE_RUN_INDEX = 'runs_dispatch_candidates_idx';
|
||||
export const RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX =
|
||||
'run_attempts_dispatch_candidates_idx';
|
||||
|
||||
const manifest = {
|
||||
indexes: [
|
||||
`${RUN_DISPATCH_CANDIDATE_RUN_INDEX}(priority DESC,queued_at_ms,id) WHERE runtime queued/dispatching uncancelled`,
|
||||
`${RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX}(status,run_id,created_at_ms,id)`,
|
||||
],
|
||||
};
|
||||
|
||||
export const runDispatchCandidateManifest = manifest;
|
||||
|
||||
export const runDispatchCandidateMigration: Migration = {
|
||||
id: '0010-run-dispatch-candidates',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.addIndex(RUN_TABLE, {
|
||||
fields: [{ name: 'priority', order: 'DESC' }, 'queued_at_ms', 'id'],
|
||||
name: RUN_DISPATCH_CANDIDATE_RUN_INDEX,
|
||||
where: {
|
||||
execution_owner: 'runtime',
|
||||
status: { [Op.in]: ['queued', 'dispatching'] },
|
||||
cancel_requested_at_ms: null,
|
||||
queued_at_ms: { [Op.ne]: null },
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
['status', 'run_id', 'created_at_ms', 'id'],
|
||||
{ name: RUN_DISPATCH_CANDIDATE_ATTEMPT_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { RUN_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const RUN_RETRY_POLICY_TABLE = 'RunRetryPolicies';
|
||||
export const RUN_RETRY_POLICY_DUE_INDEX = 'run_retry_policies_due_idx';
|
||||
export const RUN_LOST_RETRY_INDEX = 'runs_lost_retry_idx';
|
||||
|
||||
const manifest = {
|
||||
table: RUN_RETRY_POLICY_TABLE,
|
||||
columns: [
|
||||
'run_id',
|
||||
'max_attempts',
|
||||
'retry_on_lost',
|
||||
'safety',
|
||||
'backoff_base_ms',
|
||||
'backoff_max_ms',
|
||||
'next_attempt_at_ms',
|
||||
'version',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${RUN_LOST_RETRY_INDEX}(execution_owner,status,id)`,
|
||||
`${RUN_RETRY_POLICY_DUE_INDEX}(next_attempt_at_ms,run_id) WHERE next_attempt_at_ms IS NOT NULL`,
|
||||
],
|
||||
constraints: [
|
||||
'run_retry_policies_max_attempts_check',
|
||||
'run_retry_policies_backoff_base_check',
|
||||
'run_retry_policies_backoff_max_check',
|
||||
'run_retry_policies_version_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const runRetryPolicyManifest = manifest;
|
||||
|
||||
export const runRetryPolicyMigration: Migration = {
|
||||
id: '0011-run-retry-policy',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
RUN_RETRY_POLICY_TABLE,
|
||||
{
|
||||
run_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: RUN_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
max_attempts: { type: DataTypes.INTEGER, allowNull: false },
|
||||
retry_on_lost: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
safety: { type: DataTypes.STRING(16), allowNull: false },
|
||||
backoff_base_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
backoff_max_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
||||
fields: ['max_attempts'],
|
||||
type: 'check',
|
||||
where: { max_attempts: { [Op.between]: [1, 16] } },
|
||||
name: 'run_retry_policies_max_attempts_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
||||
fields: ['backoff_base_ms'],
|
||||
type: 'check',
|
||||
where: { backoff_base_ms: { [Op.gte]: 0 } },
|
||||
name: 'run_retry_policies_backoff_base_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
||||
fields: ['backoff_max_ms'],
|
||||
type: 'check',
|
||||
where: { backoff_max_ms: { [Op.gte]: 0 } },
|
||||
name: 'run_retry_policies_backoff_max_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(RUN_RETRY_POLICY_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 0 } },
|
||||
name: 'run_retry_policies_version_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
RUN_TABLE,
|
||||
['execution_owner', 'status', 'id'],
|
||||
{
|
||||
name: RUN_LOST_RETRY_INDEX,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
RUN_RETRY_POLICY_TABLE,
|
||||
['next_attempt_at_ms', 'run_id'],
|
||||
{
|
||||
name: RUN_RETRY_POLICY_DUE_INDEX,
|
||||
where: { next_attempt_at_ms: { [Op.ne]: null } },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const TASK_EXECUTION_REVISION_TABLE = 'TaskExecutionRevisions';
|
||||
export const TASK_EXECUTION_REVISION_CREATED_INDEX =
|
||||
'task_execution_revisions_created_idx';
|
||||
|
||||
const manifest = {
|
||||
table: TASK_EXECUTION_REVISION_TABLE,
|
||||
columns: [
|
||||
'project_id',
|
||||
'task_id',
|
||||
'task_revision',
|
||||
'executor_type',
|
||||
'execution_template',
|
||||
'context_ref',
|
||||
'content_digest',
|
||||
'created_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${TASK_EXECUTION_REVISION_CREATED_INDEX}(project_id,created_at_ms,task_id,task_revision)`,
|
||||
],
|
||||
constraints: ['task_execution_revisions_created_at_check'],
|
||||
};
|
||||
|
||||
export const taskExecutionRevisionManifest = manifest;
|
||||
|
||||
export const taskExecutionRevisionMigration: Migration = {
|
||||
id: '0012-task-execution-revisions',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
TASK_EXECUTION_REVISION_TABLE,
|
||||
{
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
task_revision: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
executor_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
execution_template: { type: DataTypes.TEXT, allowNull: false },
|
||||
context_ref: { type: DataTypes.STRING(512), allowNull: false },
|
||||
content_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(TASK_EXECUTION_REVISION_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'task_execution_revisions_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
TASK_EXECUTION_REVISION_TABLE,
|
||||
['project_id', 'created_at_ms', 'task_id', 'task_revision'],
|
||||
{ name: TASK_EXECUTION_REVISION_CREATED_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE =
|
||||
'LocalExecutionContextRecipes';
|
||||
export const LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX =
|
||||
'local_execution_context_recipes_created_idx';
|
||||
|
||||
const manifest = {
|
||||
table: LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
||||
columns: [
|
||||
'context_ref',
|
||||
'environment_recipe',
|
||||
'content_digest',
|
||||
'created_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX}(created_at_ms,context_ref)`,
|
||||
],
|
||||
constraints: ['local_execution_context_recipes_created_at_check'],
|
||||
};
|
||||
|
||||
export const localExecutionContextRecipeManifest = manifest;
|
||||
|
||||
export const localExecutionContextRecipeMigration: Migration = {
|
||||
id: '0013-local-execution-context-recipes',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
||||
{
|
||||
context_ref: {
|
||||
type: DataTypes.STRING(512),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
environment_recipe: { type: DataTypes.TEXT, allowNull: false },
|
||||
content_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'local_execution_context_recipes_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
||||
['created_at_ms', 'context_ref'],
|
||||
{ name: LOCAL_EXECUTION_CONTEXT_RECIPE_CREATED_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const LOCAL_SECRET_ENVELOPE_TABLE = 'LocalSecretEnvelopes';
|
||||
export const LOCAL_SECRET_MUTATION_INDEX = 'local_secret_mutation_idx';
|
||||
export const LOCAL_SECRET_CURRENT_INDEX = 'local_secret_current_idx';
|
||||
export const LOCAL_SECRET_KEY_INDEX = 'local_secret_key_idx';
|
||||
|
||||
const manifest = {
|
||||
table: LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
columns: [
|
||||
'project_id',
|
||||
'secret_name',
|
||||
'version',
|
||||
'mutation_id',
|
||||
'key_id',
|
||||
'algorithm',
|
||||
'nonce',
|
||||
'ciphertext',
|
||||
'auth_tag',
|
||||
'created_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${LOCAL_SECRET_MUTATION_INDEX}(project_id,secret_name,mutation_id) UNIQUE`,
|
||||
`${LOCAL_SECRET_CURRENT_INDEX}(project_id,secret_name,version DESC)`,
|
||||
`${LOCAL_SECRET_KEY_INDEX}(key_id,project_id,secret_name,version)`,
|
||||
],
|
||||
constraints: [
|
||||
'local_secret_envelopes_version_check',
|
||||
'local_secret_envelopes_created_at_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const localSecretEnvelopeManifest = manifest;
|
||||
|
||||
export const localSecretEnvelopeMigration: Migration = {
|
||||
id: '0014-local-secret-envelopes',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
{
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
secret_name: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
||||
key_id: { type: DataTypes.STRING(128), allowNull: false },
|
||||
algorithm: { type: DataTypes.STRING(32), allowNull: false },
|
||||
nonce: { type: DataTypes.BLOB, allowNull: false },
|
||||
ciphertext: { type: DataTypes.BLOB, allowNull: false },
|
||||
auth_tag: { type: DataTypes.BLOB, allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(LOCAL_SECRET_ENVELOPE_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 1 } },
|
||||
name: 'local_secret_envelopes_version_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(LOCAL_SECRET_ENVELOPE_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'local_secret_envelopes_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
['project_id', 'secret_name', 'mutation_id'],
|
||||
{ name: LOCAL_SECRET_MUTATION_INDEX, unique: true, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(LOCAL_SECRET_ENVELOPE_TABLE, {
|
||||
fields: ['project_id', 'secret_name', { name: 'version', order: 'DESC' }],
|
||||
name: LOCAL_SECRET_CURRENT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
['key_id', 'project_id', 'secret_name', 'version'],
|
||||
{ name: LOCAL_SECRET_KEY_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE } from './0002-run-schema';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const LOCAL_ARTIFACT_RETENTION_TABLE = 'LocalArtifactRetentions';
|
||||
export const LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX =
|
||||
'local_artifact_retention_recorded_idx';
|
||||
export const RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX =
|
||||
'run_attempts_artifact_retention_idx';
|
||||
|
||||
const manifest = {
|
||||
table: LOCAL_ARTIFACT_RETENTION_TABLE,
|
||||
columns: [
|
||||
'attempt_id',
|
||||
'log_artifact_id',
|
||||
'finished_at_ms',
|
||||
'eligible_at_ms',
|
||||
'disposition',
|
||||
'bytes_reclaimed',
|
||||
'recorded_at_ms',
|
||||
],
|
||||
indexes: [
|
||||
`${LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX}(recorded_at_ms,attempt_id)`,
|
||||
`${RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX}(status,finished_at_ms,id)`,
|
||||
],
|
||||
constraints: [
|
||||
'local_artifact_retention_disposition_check',
|
||||
'local_artifact_retention_finished_check',
|
||||
'local_artifact_retention_eligible_check',
|
||||
'local_artifact_retention_bytes_check',
|
||||
'local_artifact_retention_recorded_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const localArtifactRetentionManifest = manifest;
|
||||
|
||||
export const localArtifactRetentionMigration: Migration = {
|
||||
id: '0015-local-artifact-retention',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
LOCAL_ARTIFACT_RETENTION_TABLE,
|
||||
{
|
||||
attempt_id: {
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: RUN_ATTEMPT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
log_artifact_id: { type: DataTypes.STRING(36), allowNull: false },
|
||||
finished_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
eligible_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
disposition: { type: DataTypes.STRING(16), allowNull: false },
|
||||
bytes_reclaimed: { type: DataTypes.BIGINT, allowNull: false },
|
||||
recorded_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(LOCAL_ARTIFACT_RETENTION_TABLE, {
|
||||
fields: ['disposition'],
|
||||
type: 'check',
|
||||
where: { disposition: { [Op.in]: ['deleted', 'already_absent'] } },
|
||||
name: 'local_artifact_retention_disposition_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [field, name] of [
|
||||
['finished_at_ms', 'local_artifact_retention_finished_check'],
|
||||
['eligible_at_ms', 'local_artifact_retention_eligible_check'],
|
||||
['bytes_reclaimed', 'local_artifact_retention_bytes_check'],
|
||||
['recorded_at_ms', 'local_artifact_retention_recorded_check'],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(LOCAL_ARTIFACT_RETENTION_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.gte]: 0 } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addIndex(
|
||||
LOCAL_ARTIFACT_RETENTION_TABLE,
|
||||
['recorded_at_ms', 'attempt_id'],
|
||||
{ name: LOCAL_ARTIFACT_RETENTION_RECORDED_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
RUN_ATTEMPT_TABLE,
|
||||
['status', 'finished_at_ms', 'id'],
|
||||
{ name: RUN_ATTEMPT_ARTIFACT_RETENTION_INDEX, transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE =
|
||||
'LocalArtifactMaintenanceCursors';
|
||||
|
||||
const manifest = {
|
||||
table: LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
columns: [
|
||||
'scope',
|
||||
'cursor_finished_at_ms',
|
||||
'cursor_attempt_id',
|
||||
'version',
|
||||
'updated_at_ms',
|
||||
],
|
||||
indexes: [] as string[],
|
||||
constraints: [
|
||||
'local_artifact_cursor_scope_check',
|
||||
'local_artifact_cursor_pair_check',
|
||||
'local_artifact_cursor_version_check',
|
||||
'local_artifact_cursor_updated_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const localArtifactMaintenanceCursorManifest = manifest;
|
||||
|
||||
export const localArtifactMaintenanceCursorMigration: Migration = {
|
||||
id: '0016-local-artifact-maintenance-cursor',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
{
|
||||
scope: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
cursor_finished_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
cursor_attempt_id: { type: DataTypes.STRING(36), allowNull: true },
|
||||
version: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
{
|
||||
fields: ['scope'],
|
||||
type: 'check',
|
||||
where: { scope: 'retention' },
|
||||
name: 'local_artifact_cursor_scope_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
{
|
||||
fields: ['cursor_finished_at_ms', 'cursor_attempt_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ cursor_finished_at_ms: null, cursor_attempt_id: null },
|
||||
{
|
||||
cursor_finished_at_ms: { [Op.not]: null },
|
||||
cursor_attempt_id: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'local_artifact_cursor_pair_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
{
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 1 } },
|
||||
name: 'local_artifact_cursor_version_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
{
|
||||
fields: ['updated_at_ms'],
|
||||
type: 'check',
|
||||
where: { updated_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'local_artifact_cursor_updated_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,227 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
POLICY_SUBJECT_TYPES,
|
||||
PROJECT_ROLES,
|
||||
PROJECT_ROLE_BINDING_STATES,
|
||||
PROJECT_STATUSES,
|
||||
} from '../runtime/domain/projectPolicy';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const PROJECT_TABLE = 'Projects';
|
||||
export const PROJECT_ROLE_BINDING_TABLE = 'ProjectRoleBindings';
|
||||
export const PROJECT_SLUG_INDEX = 'projects_slug_uidx';
|
||||
export const PROJECT_ROLE_BINDING_CURRENT_INDEX =
|
||||
'project_role_binding_current_idx';
|
||||
export const PROJECT_ROLE_BINDING_MUTATION_INDEX =
|
||||
'project_role_binding_mutation_uidx';
|
||||
export const PROJECT_ROLE_BINDING_SUBJECT_INDEX =
|
||||
'project_role_binding_subject_idx';
|
||||
|
||||
const manifest = {
|
||||
tables: {
|
||||
Projects: [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'status',
|
||||
'version',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
ProjectRoleBindings: [
|
||||
'project_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'version',
|
||||
'state',
|
||||
'role',
|
||||
'mutation_id',
|
||||
'changed_by_type',
|
||||
'changed_by_id',
|
||||
'created_at_ms',
|
||||
],
|
||||
},
|
||||
indexes: [
|
||||
`${PROJECT_SLUG_INDEX}(slug) UNIQUE`,
|
||||
`${PROJECT_ROLE_BINDING_CURRENT_INDEX}(project_id,subject_type,subject_id,version DESC)`,
|
||||
`${PROJECT_ROLE_BINDING_MUTATION_INDEX}(project_id,mutation_id) UNIQUE`,
|
||||
`${PROJECT_ROLE_BINDING_SUBJECT_INDEX}(subject_type,subject_id,project_id,version DESC)`,
|
||||
],
|
||||
constraints: [
|
||||
'projects_status_check',
|
||||
'projects_version_check',
|
||||
'projects_created_at_check',
|
||||
'projects_updated_at_check',
|
||||
'project_role_bindings_subject_type_check',
|
||||
'project_role_bindings_version_check',
|
||||
'project_role_bindings_state_check',
|
||||
'project_role_bindings_role_check',
|
||||
'project_role_bindings_changed_by_type_check',
|
||||
'project_role_bindings_created_at_check',
|
||||
],
|
||||
baseline: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const projectPolicyManifest = manifest;
|
||||
|
||||
export const projectPolicyMigration: Migration = {
|
||||
id: '0017-project-policy',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
PROJECT_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(255), allowNull: false },
|
||||
slug: { type: DataTypes.STRING(128), allowNull: false },
|
||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(PROJECT_TABLE, {
|
||||
fields: ['status'],
|
||||
type: 'check',
|
||||
where: { status: { [Op.in]: PROJECT_STATUSES } },
|
||||
name: 'projects_status_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [field, name, minimum] of [
|
||||
['version', 'projects_version_check', 1],
|
||||
['created_at_ms', 'projects_created_at_check', 0],
|
||||
['updated_at_ms', 'projects_updated_at_check', 0],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(PROJECT_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.gte]: minimum } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addIndex(PROJECT_TABLE, ['slug'], {
|
||||
name: PROJECT_SLUG_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.bulkInsert(PROJECT_TABLE, [manifest.baseline], {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await queryInterface.createTable(
|
||||
PROJECT_ROLE_BINDING_TABLE,
|
||||
{
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
subject_type: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
subject_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
role: { type: DataTypes.STRING(16), allowNull: true },
|
||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
||||
changed_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
||||
changed_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
for (const [field, values, name] of [
|
||||
[
|
||||
'subject_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'project_role_bindings_subject_type_check',
|
||||
],
|
||||
[
|
||||
'state',
|
||||
PROJECT_ROLE_BINDING_STATES,
|
||||
'project_role_bindings_state_check',
|
||||
],
|
||||
['role', PROJECT_ROLES, 'project_role_bindings_role_check'],
|
||||
[
|
||||
'changed_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'project_role_bindings_changed_by_type_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 1 } },
|
||||
name: 'project_role_bindings_version_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(PROJECT_ROLE_BINDING_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'project_role_bindings_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(PROJECT_ROLE_BINDING_TABLE, {
|
||||
fields: [
|
||||
'project_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
{ name: 'version', order: 'DESC' },
|
||||
],
|
||||
name: PROJECT_ROLE_BINDING_CURRENT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
PROJECT_ROLE_BINDING_TABLE,
|
||||
['project_id', 'mutation_id'],
|
||||
{
|
||||
name: PROJECT_ROLE_BINDING_MUTATION_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(PROJECT_ROLE_BINDING_TABLE, {
|
||||
fields: [
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'project_id',
|
||||
{ name: 'version', order: 'DESC' },
|
||||
],
|
||||
name: PROJECT_ROLE_BINDING_SUBJECT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
||||
import { PROJECT_TABLE } from './0017-project-policy';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE =
|
||||
'ProjectOwnerBootstrapChallenges';
|
||||
export const PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX =
|
||||
'project_owner_bootstrap_challenge_id_uidx';
|
||||
export const PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX =
|
||||
'project_owner_bootstrap_current_idx';
|
||||
|
||||
const manifest = {
|
||||
table: PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
columns: [
|
||||
'project_id',
|
||||
'version',
|
||||
'challenge_id',
|
||||
'token_digest',
|
||||
'issued_at_ms',
|
||||
'expires_at_ms',
|
||||
'consumed_at_ms',
|
||||
'claimed_subject_type',
|
||||
'claimed_subject_id',
|
||||
],
|
||||
indexes: [
|
||||
`${PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX}(challenge_id) UNIQUE`,
|
||||
`${PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX}(project_id,version DESC)`,
|
||||
],
|
||||
constraints: [
|
||||
'project_owner_bootstrap_version_check',
|
||||
'project_owner_bootstrap_issued_at_check',
|
||||
'project_owner_bootstrap_lifetime_check',
|
||||
'project_owner_bootstrap_claim_tuple_check',
|
||||
'project_owner_bootstrap_claimed_subject_type_check',
|
||||
],
|
||||
};
|
||||
|
||||
export const projectOwnerBootstrapManifest = manifest;
|
||||
|
||||
export const projectOwnerBootstrapMigration: Migration = {
|
||||
id: '0018-project-owner-bootstrap',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
challenge_id: { type: DataTypes.STRING(22), allowNull: false },
|
||||
token_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
issued_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
consumed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
claimed_subject_type: { type: DataTypes.STRING(32), allowNull: true },
|
||||
claimed_subject_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 1 } },
|
||||
name: 'project_owner_bootstrap_version_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
fields: ['issued_at_ms'],
|
||||
type: 'check',
|
||||
where: { issued_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'project_owner_bootstrap_issued_at_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
fields: ['expires_at_ms', 'issued_at_ms'],
|
||||
type: 'check',
|
||||
where: { expires_at_ms: { [Op.gt]: { [Op.col]: 'issued_at_ms' } } },
|
||||
name: 'project_owner_bootstrap_lifetime_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
fields: [
|
||||
'consumed_at_ms',
|
||||
'claimed_subject_type',
|
||||
'claimed_subject_id',
|
||||
],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
consumed_at_ms: null,
|
||||
claimed_subject_type: null,
|
||||
claimed_subject_id: null,
|
||||
},
|
||||
{
|
||||
consumed_at_ms: { [Op.not]: null },
|
||||
claimed_subject_type: { [Op.not]: null },
|
||||
claimed_subject_id: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'project_owner_bootstrap_claim_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
{
|
||||
fields: ['claimed_subject_type'],
|
||||
type: 'check',
|
||||
where: { claimed_subject_type: { [Op.in]: POLICY_SUBJECT_TYPES } },
|
||||
name: 'project_owner_bootstrap_claimed_subject_type_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
['challenge_id'],
|
||||
{
|
||||
name: PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE, {
|
||||
fields: ['project_id', { name: 'version', order: 'DESC' }],
|
||||
name: PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
IDENTITY_AUTHENTICATION_BINDING_STATES,
|
||||
IDENTITY_SUBJECT_STATUSES,
|
||||
LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
LEGACY_PRIMARY_USER_SUBJECT_ID,
|
||||
} from '../runtime/domain/identityDirectory';
|
||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const IDENTITY_SUBJECT_TABLE = 'IdentitySubjects';
|
||||
export const IDENTITY_AUTHENTICATION_BINDING_TABLE =
|
||||
'IdentityAuthenticationBindings';
|
||||
export const IDENTITY_SUBJECT_STATUS_INDEX = 'identity_subject_status_idx';
|
||||
export const IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX =
|
||||
'identity_auth_binding_current_idx';
|
||||
export const IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX =
|
||||
'identity_auth_binding_subject_idx';
|
||||
|
||||
const manifest = {
|
||||
tables: {
|
||||
IdentitySubjects: [
|
||||
'id',
|
||||
'type',
|
||||
'status',
|
||||
'version',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
],
|
||||
IdentityAuthenticationBindings: [
|
||||
'provider',
|
||||
'provider_subject',
|
||||
'version',
|
||||
'state',
|
||||
'subject_id',
|
||||
'created_at_ms',
|
||||
],
|
||||
},
|
||||
indexes: [
|
||||
`${IDENTITY_SUBJECT_STATUS_INDEX}(type,status,id)`,
|
||||
`${IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX}(provider,provider_subject,version DESC)`,
|
||||
`${IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX}(subject_id,provider,provider_subject,version DESC)`,
|
||||
],
|
||||
constraints: [
|
||||
'identity_subject_type_check',
|
||||
'identity_subject_status_check',
|
||||
'identity_subject_version_check',
|
||||
'identity_subject_created_at_check',
|
||||
'identity_subject_updated_at_check',
|
||||
'identity_auth_binding_version_check',
|
||||
'identity_auth_binding_state_check',
|
||||
'identity_auth_binding_created_at_check',
|
||||
],
|
||||
baseline: {
|
||||
subject: {
|
||||
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
||||
type: 'user',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
},
|
||||
binding: {
|
||||
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
|
||||
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
|
||||
created_at_ms: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const identityDirectoryManifest = manifest;
|
||||
|
||||
export const identityDirectoryMigration: Migration = {
|
||||
id: '0019-identity-directory',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
IDENTITY_SUBJECT_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(255), allowNull: false, primaryKey: true },
|
||||
type: { type: DataTypes.STRING(32), allowNull: false },
|
||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
for (const [field, values, name] of [
|
||||
['type', POLICY_SUBJECT_TYPES, 'identity_subject_type_check'],
|
||||
['status', IDENTITY_SUBJECT_STATUSES, 'identity_subject_status_check'],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(IDENTITY_SUBJECT_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const [field, name, minimum] of [
|
||||
['version', 'identity_subject_version_check', 1],
|
||||
['created_at_ms', 'identity_subject_created_at_check', 0],
|
||||
['updated_at_ms', 'identity_subject_updated_at_check', 0],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(IDENTITY_SUBJECT_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.gte]: minimum } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addIndex(IDENTITY_SUBJECT_TABLE, {
|
||||
fields: ['type', 'status', 'id'],
|
||||
name: IDENTITY_SUBJECT_STATUS_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.bulkInsert(
|
||||
IDENTITY_SUBJECT_TABLE,
|
||||
[manifest.baseline.subject],
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await queryInterface.createTable(
|
||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
||||
{
|
||||
provider: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
provider_subject: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
subject_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
references: { model: IDENTITY_SUBJECT_TABLE, key: 'id' },
|
||||
onDelete: 'RESTRICT',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.gte]: 1 } },
|
||||
name: 'identity_auth_binding_version_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
||||
fields: ['state'],
|
||||
type: 'check',
|
||||
where: {
|
||||
state: { [Op.in]: IDENTITY_AUTHENTICATION_BINDING_STATES },
|
||||
},
|
||||
name: 'identity_auth_binding_state_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'identity_auth_binding_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
||||
fields: [
|
||||
'provider',
|
||||
'provider_subject',
|
||||
{ name: 'version', order: 'DESC' },
|
||||
],
|
||||
name: IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE, {
|
||||
fields: [
|
||||
'subject_id',
|
||||
'provider',
|
||||
'provider_subject',
|
||||
{ name: 'version', order: 'DESC' },
|
||||
],
|
||||
name: IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.bulkInsert(
|
||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
||||
[manifest.baseline.binding],
|
||||
{ transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,436 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
APPROVAL_DECISIONS,
|
||||
APPROVAL_REQUEST_STATES,
|
||||
APPROVAL_RISKS,
|
||||
APPROVED_ACTION_DISPATCH_STATES,
|
||||
} from '../runtime/domain/approvalRequest';
|
||||
import { POLICY_SUBJECT_TYPES } from '../runtime/domain/projectPolicy';
|
||||
import { PROJECT_TABLE } from './0017-project-policy';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const APPROVAL_REQUEST_TABLE = 'ApprovalRequests';
|
||||
export const APPROVED_ACTION_DISPATCH_TABLE = 'ApprovedActionDispatches';
|
||||
export const APPROVAL_REQUEST_DECISION_ID_INDEX =
|
||||
'approval_request_decision_id_uidx';
|
||||
export const APPROVAL_REQUEST_CONSUMPTION_ID_INDEX =
|
||||
'approval_request_consumption_id_uidx';
|
||||
export const APPROVAL_REQUEST_PENDING_INDEX = 'approval_request_pending_idx';
|
||||
export const APPROVAL_REQUEST_REQUESTER_INDEX =
|
||||
'approval_request_requester_idx';
|
||||
export const APPROVED_ACTION_DISPATCH_REQUEST_INDEX =
|
||||
'approved_action_dispatch_request_uidx';
|
||||
export const APPROVED_ACTION_DISPATCH_PENDING_INDEX =
|
||||
'approved_action_dispatch_pending_idx';
|
||||
|
||||
const approvalRequestColumns = [
|
||||
'id',
|
||||
'project_id',
|
||||
'version',
|
||||
'state',
|
||||
'permission',
|
||||
'action_type',
|
||||
'action_ref',
|
||||
'action_digest',
|
||||
'preview_digest',
|
||||
'risk',
|
||||
'requested_by_type',
|
||||
'requested_by_id',
|
||||
'requested_at_ms',
|
||||
'expires_at_ms',
|
||||
'decision_id',
|
||||
'decision',
|
||||
'decision_reason_code',
|
||||
'decided_by_type',
|
||||
'decided_by_id',
|
||||
'decided_at_ms',
|
||||
'consumption_id',
|
||||
'dispatch_id',
|
||||
'consumed_by_type',
|
||||
'consumed_by_id',
|
||||
'consumed_at_ms',
|
||||
];
|
||||
|
||||
const approvedActionDispatchColumns = [
|
||||
'id',
|
||||
'approval_request_id',
|
||||
'approval_request_version',
|
||||
'project_id',
|
||||
'state',
|
||||
'permission',
|
||||
'action_type',
|
||||
'action_ref',
|
||||
'action_digest',
|
||||
'preview_digest',
|
||||
'requested_by_type',
|
||||
'requested_by_id',
|
||||
'consumed_by_type',
|
||||
'consumed_by_id',
|
||||
'created_at_ms',
|
||||
];
|
||||
|
||||
const constraints = [
|
||||
'approval_request_version_check',
|
||||
'approval_request_state_check',
|
||||
'approval_request_risk_check',
|
||||
'approval_request_requester_type_check',
|
||||
'approval_request_lifetime_check',
|
||||
'approval_request_decision_tuple_check',
|
||||
'approval_request_decision_value_check',
|
||||
'approval_request_decided_by_type_check',
|
||||
'approval_request_decision_time_check',
|
||||
'approval_request_consumption_tuple_check',
|
||||
'approval_request_consumed_by_type_check',
|
||||
'approval_request_consumption_time_check',
|
||||
'approval_request_state_tuple_check',
|
||||
'approved_action_dispatch_version_check',
|
||||
'approved_action_dispatch_state_check',
|
||||
'approved_action_dispatch_requester_type_check',
|
||||
'approved_action_dispatch_consumer_type_check',
|
||||
'approved_action_dispatch_created_at_check',
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
tables: {
|
||||
ApprovalRequests: approvalRequestColumns,
|
||||
ApprovedActionDispatches: approvedActionDispatchColumns,
|
||||
},
|
||||
indexes: [
|
||||
`${APPROVAL_REQUEST_DECISION_ID_INDEX}(decision_id) UNIQUE`,
|
||||
`${APPROVAL_REQUEST_CONSUMPTION_ID_INDEX}(consumption_id) UNIQUE`,
|
||||
`${APPROVAL_REQUEST_PENDING_INDEX}(project_id,state,expires_at_ms,id)`,
|
||||
`${APPROVAL_REQUEST_REQUESTER_INDEX}(project_id,requested_by_type,requested_by_id,requested_at_ms DESC,id)`,
|
||||
`${APPROVED_ACTION_DISPATCH_REQUEST_INDEX}(approval_request_id) UNIQUE`,
|
||||
`${APPROVED_ACTION_DISPATCH_PENDING_INDEX}(project_id,state,created_at_ms,id)`,
|
||||
],
|
||||
constraints,
|
||||
};
|
||||
|
||||
export const approvalRequestManifest = manifest;
|
||||
|
||||
function nullableTuple(...fields: string[]) {
|
||||
return {
|
||||
[Op.or]: [
|
||||
Object.fromEntries(fields.map((field) => [field, null])),
|
||||
Object.fromEntries(fields.map((field) => [field, { [Op.not]: null }])),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const approvalRequestMigration: Migration = {
|
||||
id: '0020-approval-requests',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
APPROVAL_REQUEST_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
||||
action_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
action_ref: { type: DataTypes.STRING(255), allowNull: false },
|
||||
action_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
preview_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
risk: { type: DataTypes.STRING(16), allowNull: false },
|
||||
requested_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
||||
requested_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
||||
requested_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
expires_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
decision_id: { type: DataTypes.STRING(64), allowNull: true },
|
||||
decision: { type: DataTypes.STRING(16), allowNull: true },
|
||||
decision_reason_code: { type: DataTypes.STRING(64), allowNull: true },
|
||||
decided_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
||||
decided_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
decided_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
consumption_id: { type: DataTypes.STRING(64), allowNull: true },
|
||||
dispatch_id: { type: DataTypes.STRING(64), allowNull: true },
|
||||
consumed_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
||||
consumed_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
consumed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['version'],
|
||||
type: 'check',
|
||||
where: { version: { [Op.between]: [1, 3] } },
|
||||
name: 'approval_request_version_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [field, values, name] of [
|
||||
['state', APPROVAL_REQUEST_STATES, 'approval_request_state_check'],
|
||||
['risk', APPROVAL_RISKS, 'approval_request_risk_check'],
|
||||
[
|
||||
'requested_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'approval_request_requester_type_check',
|
||||
],
|
||||
[
|
||||
'decided_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'approval_request_decided_by_type_check',
|
||||
],
|
||||
[
|
||||
'consumed_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'approval_request_consumed_by_type_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['requested_at_ms', 'expires_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
requested_at_ms: { [Op.gte]: 0 },
|
||||
expires_at_ms: { [Op.gt]: { [Op.col]: 'requested_at_ms' } },
|
||||
},
|
||||
name: 'approval_request_lifetime_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: [
|
||||
'decision_id',
|
||||
'decision',
|
||||
'decision_reason_code',
|
||||
'decided_by_type',
|
||||
'decided_by_id',
|
||||
'decided_at_ms',
|
||||
],
|
||||
type: 'check',
|
||||
where: nullableTuple(
|
||||
'decision_id',
|
||||
'decision',
|
||||
'decision_reason_code',
|
||||
'decided_by_type',
|
||||
'decided_by_id',
|
||||
'decided_at_ms',
|
||||
),
|
||||
name: 'approval_request_decision_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['decision'],
|
||||
type: 'check',
|
||||
where: { decision: { [Op.in]: APPROVAL_DECISIONS } },
|
||||
name: 'approval_request_decision_value_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['decided_at_ms', 'requested_at_ms', 'expires_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ decided_at_ms: null },
|
||||
{
|
||||
decided_at_ms: {
|
||||
[Op.gte]: { [Op.col]: 'requested_at_ms' },
|
||||
[Op.lt]: { [Op.col]: 'expires_at_ms' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approval_request_decision_time_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: [
|
||||
'consumption_id',
|
||||
'dispatch_id',
|
||||
'consumed_by_type',
|
||||
'consumed_by_id',
|
||||
'consumed_at_ms',
|
||||
],
|
||||
type: 'check',
|
||||
where: nullableTuple(
|
||||
'consumption_id',
|
||||
'dispatch_id',
|
||||
'consumed_by_type',
|
||||
'consumed_by_id',
|
||||
'consumed_at_ms',
|
||||
),
|
||||
name: 'approval_request_consumption_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['consumed_at_ms', 'decided_at_ms', 'expires_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ consumed_at_ms: null },
|
||||
{
|
||||
consumed_at_ms: {
|
||||
[Op.gte]: { [Op.col]: 'decided_at_ms' },
|
||||
[Op.lt]: { [Op.col]: 'expires_at_ms' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approval_request_consumption_time_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['state', 'version', 'decision', 'decision_id', 'consumption_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
state: 'pending',
|
||||
version: 1,
|
||||
decision_id: null,
|
||||
consumption_id: null,
|
||||
},
|
||||
{
|
||||
state: 'approved',
|
||||
version: 2,
|
||||
decision: 'approved',
|
||||
consumption_id: null,
|
||||
},
|
||||
{
|
||||
state: 'rejected',
|
||||
version: 2,
|
||||
decision: 'rejected',
|
||||
consumption_id: null,
|
||||
},
|
||||
{
|
||||
state: 'consumed',
|
||||
version: 3,
|
||||
decision: 'approved',
|
||||
consumption_id: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approval_request_state_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
|
||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, ['decision_id'], {
|
||||
name: APPROVAL_REQUEST_DECISION_ID_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, ['consumption_id'], {
|
||||
name: APPROVAL_REQUEST_CONSUMPTION_ID_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, {
|
||||
fields: ['project_id', 'state', 'expires_at_ms', 'id'],
|
||||
name: APPROVAL_REQUEST_PENDING_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVAL_REQUEST_TABLE, {
|
||||
fields: [
|
||||
'project_id',
|
||||
'requested_by_type',
|
||||
'requested_by_id',
|
||||
{ name: 'requested_at_ms', order: 'DESC' },
|
||||
'id',
|
||||
],
|
||||
name: APPROVAL_REQUEST_REQUESTER_INDEX,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_DISPATCH_TABLE,
|
||||
{
|
||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
||||
approval_request_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
references: { model: APPROVAL_REQUEST_TABLE, key: 'id' },
|
||||
onDelete: 'RESTRICT',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
approval_request_version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
||||
action_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
action_ref: { type: DataTypes.STRING(255), allowNull: false },
|
||||
action_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
preview_digest: { type: DataTypes.STRING(64), allowNull: false },
|
||||
requested_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
||||
requested_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
||||
consumed_by_type: { type: DataTypes.STRING(32), allowNull: false },
|
||||
consumed_by_id: { type: DataTypes.STRING(255), allowNull: false },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
||||
fields: ['approval_request_version'],
|
||||
type: 'check',
|
||||
where: { approval_request_version: 3 },
|
||||
name: 'approved_action_dispatch_version_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [field, values, name] of [
|
||||
[
|
||||
'state',
|
||||
APPROVED_ACTION_DISPATCH_STATES,
|
||||
'approved_action_dispatch_state_check',
|
||||
],
|
||||
[
|
||||
'requested_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'approved_action_dispatch_requester_type_check',
|
||||
],
|
||||
[
|
||||
'consumed_by_type',
|
||||
POLICY_SUBJECT_TYPES,
|
||||
'approved_action_dispatch_consumer_type_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_DISPATCH_TABLE, {
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'approved_action_dispatch_created_at_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_DISPATCH_TABLE,
|
||||
['approval_request_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_DISPATCH_REQUEST_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_TABLE, {
|
||||
fields: ['project_id', 'state', 'created_at_ms', 'id'],
|
||||
name: APPROVED_ACTION_DISPATCH_PENDING_INDEX,
|
||||
transaction,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,227 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
APPROVED_ACTION_EXECUTION_STATUSES,
|
||||
DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
|
||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
||||
} from '../runtime/domain/approvedActionDispatchExecution';
|
||||
import { PROJECT_TABLE } from './0017-project-policy';
|
||||
import { APPROVED_ACTION_DISPATCH_TABLE } from './0020-approval-requests';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_TABLE =
|
||||
'ApprovedActionDispatchExecutions';
|
||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX =
|
||||
'approved_action_execution_due_idx';
|
||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX =
|
||||
'approved_action_execution_project_idx';
|
||||
export const APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX =
|
||||
'approved_action_execution_lease_idx';
|
||||
|
||||
const columns = [
|
||||
'dispatch_id',
|
||||
'project_id',
|
||||
'status',
|
||||
'version',
|
||||
'attempt_count',
|
||||
'max_attempts',
|
||||
'eligible_at_ms',
|
||||
'next_attempt_at_ms',
|
||||
'lease_owner',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'started_at_ms',
|
||||
'result_mutation_id',
|
||||
'last_result_code',
|
||||
'completed_at_ms',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
table: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
columns,
|
||||
indexes: [
|
||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX}(eligible_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
|
||||
],
|
||||
constraints: [
|
||||
'approved_action_execution_status_check',
|
||||
'approved_action_execution_version_check',
|
||||
'approved_action_execution_attempt_count_check',
|
||||
'approved_action_execution_max_attempts_check',
|
||||
'approved_action_execution_attempt_budget_check',
|
||||
'approved_action_execution_lease_tuple_check',
|
||||
'approved_action_execution_created_at_check',
|
||||
'approved_action_execution_updated_at_check',
|
||||
],
|
||||
baselineMaxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
|
||||
};
|
||||
|
||||
export const approvedActionDispatchExecutionManifest = manifest;
|
||||
|
||||
export const approvedActionDispatchExecutionMigration: Migration = {
|
||||
id: '0021-approved-action-dispatch-executions',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
dispatch_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: APPROVED_ACTION_DISPATCH_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
status: { type: DataTypes.STRING(16), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
attempt_count: { type: DataTypes.INTEGER, allowNull: false },
|
||||
max_attempts: { type: DataTypes.INTEGER, allowNull: false },
|
||||
eligible_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
next_attempt_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
started_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
result_mutation_id: { type: DataTypes.STRING(64), allowNull: true },
|
||||
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
|
||||
completed_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: ['status'],
|
||||
type: 'check',
|
||||
where: { status: { [Op.in]: APPROVED_ACTION_EXECUTION_STATUSES } },
|
||||
name: 'approved_action_execution_status_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
for (const [field, minimum, maximum, name] of [
|
||||
['version', 0, 2_147_483_647, 'approved_action_execution_version_check'],
|
||||
[
|
||||
'attempt_count',
|
||||
0,
|
||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
||||
'approved_action_execution_attempt_count_check',
|
||||
],
|
||||
[
|
||||
'max_attempts',
|
||||
1,
|
||||
MAX_APPROVED_ACTION_ATTEMPTS,
|
||||
'approved_action_execution_max_attempts_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.between]: [minimum, maximum] } },
|
||||
name,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: ['attempt_count', 'max_attempts'],
|
||||
type: 'check',
|
||||
where: { attempt_count: { [Op.lte]: { [Op.col]: 'max_attempts' } } },
|
||||
name: 'approved_action_execution_attempt_budget_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: ['lease_owner', 'lease_token', 'lease_expires_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
lease_owner: null,
|
||||
lease_token: null,
|
||||
lease_expires_at_ms: null,
|
||||
},
|
||||
{
|
||||
lease_owner: { [Op.not]: null },
|
||||
lease_token: { [Op.not]: null },
|
||||
lease_expires_at_ms: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_execution_lease_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: ['created_at_ms'],
|
||||
type: 'check',
|
||||
where: { created_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'approved_action_execution_created_at_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
{
|
||||
fields: ['updated_at_ms', 'created_at_ms'],
|
||||
type: 'check',
|
||||
where: { updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } } },
|
||||
name: 'approved_action_execution_updated_at_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
['eligible_at_ms', 'dispatch_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_DUE_INDEX,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
|
||||
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_PROJECT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVED_ACTION_DISPATCH_EXECUTION_TABLE, {
|
||||
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_LEASE_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
||||
(dispatch_id, project_id, status, version, attempt_count, max_attempts,
|
||||
eligible_at_ms, next_attempt_at_ms, lease_owner, lease_token,
|
||||
lease_expires_at_ms, started_at_ms, result_mutation_id,
|
||||
last_result_code, completed_at_ms, created_at_ms, updated_at_ms)
|
||||
SELECT id, project_id, 'pending', 0, 0, :maxAttempts,
|
||||
created_at_ms, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
created_at_ms, created_at_ms
|
||||
FROM "${APPROVED_ACTION_DISPATCH_TABLE}"`,
|
||||
{
|
||||
replacements: { maxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,447 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { DataTypes, Op } from 'sequelize';
|
||||
import {
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
|
||||
APPROVED_ACTION_RECOVERY_DECISIONS,
|
||||
APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
APPROVED_ACTION_RECOVERY_SOURCES,
|
||||
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
} from '../runtime/domain/approvedActionRecovery';
|
||||
import { PROJECT_TABLE } from './0017-project-policy';
|
||||
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from './0021-approved-action-dispatch-executions';
|
||||
import type { Migration } from './types';
|
||||
|
||||
export const APPROVED_ACTION_RECOVERY_CONTROL_TABLE =
|
||||
'ApprovedActionRecoveryControls';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE =
|
||||
'ApprovedActionRecoveryResolutions';
|
||||
export const APPROVED_ACTION_RECOVERY_DUE_INDEX =
|
||||
'approved_action_recovery_due_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_PROJECT_INDEX =
|
||||
'approved_action_recovery_project_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_LEASE_INDEX =
|
||||
'approved_action_recovery_lease_idx';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX =
|
||||
'approved_action_recovery_resolution_mutation_uidx';
|
||||
export const APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX =
|
||||
'approved_action_recovery_resolution_project_idx';
|
||||
|
||||
const controlColumns = [
|
||||
'dispatch_id',
|
||||
'project_id',
|
||||
'execution_version',
|
||||
'status',
|
||||
'version',
|
||||
'next_scan_at_ms',
|
||||
'lease_owner',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'finding_count',
|
||||
'last_finding_mutation_id',
|
||||
'last_finding',
|
||||
'last_result_code',
|
||||
'last_evidence_digest',
|
||||
'resolution_mutation_id',
|
||||
'created_at_ms',
|
||||
'updated_at_ms',
|
||||
];
|
||||
|
||||
const resolutionColumns = [
|
||||
'dispatch_id',
|
||||
'project_id',
|
||||
'execution_version',
|
||||
'mutation_id',
|
||||
'source',
|
||||
'decision',
|
||||
'evidence_digest',
|
||||
'reason_code',
|
||||
'resolved_by_type',
|
||||
'resolved_by_id',
|
||||
'resolved_at_ms',
|
||||
];
|
||||
|
||||
const constraints = [
|
||||
'approved_action_recovery_status_check',
|
||||
'approved_action_recovery_execution_version_check',
|
||||
'approved_action_recovery_version_check',
|
||||
'approved_action_recovery_finding_count_check',
|
||||
'approved_action_recovery_lease_tuple_check',
|
||||
'approved_action_recovery_finding_tuple_check',
|
||||
'approved_action_recovery_finding_value_check',
|
||||
'approved_action_recovery_evidence_digest_check',
|
||||
'approved_action_recovery_resolution_mutation_tuple_check',
|
||||
'approved_action_recovery_timestamps_check',
|
||||
'approved_action_recovery_resolution_execution_version_check',
|
||||
'approved_action_recovery_resolution_source_check',
|
||||
'approved_action_recovery_resolution_decision_check',
|
||||
'approved_action_recovery_resolution_actor_tuple_check',
|
||||
'approved_action_recovery_resolution_source_tuple_check',
|
||||
'approved_action_recovery_resolution_evidence_digest_check',
|
||||
'approved_action_recovery_resolution_time_check',
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
tables: {
|
||||
ApprovedActionRecoveryControls: controlColumns,
|
||||
ApprovedActionRecoveryResolutions: resolutionColumns,
|
||||
},
|
||||
indexes: [
|
||||
`${APPROVED_ACTION_RECOVERY_DUE_INDEX}(status,next_scan_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_PROJECT_INDEX}(project_id,status,created_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_LEASE_INDEX}(status,lease_expires_at_ms,dispatch_id)`,
|
||||
`${APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX}(mutation_id) UNIQUE`,
|
||||
`${APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX}(project_id,resolved_at_ms,dispatch_id)`,
|
||||
],
|
||||
constraints,
|
||||
};
|
||||
|
||||
export const approvedActionRecoveryManifest = manifest;
|
||||
|
||||
export const approvedActionRecoveryMigration: Migration = {
|
||||
id: '0022-approved-action-recovery',
|
||||
checksum: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
|
||||
async up({ queryInterface, transaction }) {
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
{
|
||||
dispatch_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: {
|
||||
model: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
key: 'dispatch_id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
execution_version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
status: { type: DataTypes.STRING(24), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
next_scan_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
lease_owner: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_token: { type: DataTypes.STRING(128), allowNull: true },
|
||||
lease_expires_at_ms: { type: DataTypes.BIGINT, allowNull: true },
|
||||
finding_count: { type: DataTypes.INTEGER, allowNull: false },
|
||||
last_finding_mutation_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
last_finding: { type: DataTypes.STRING(24), allowNull: true },
|
||||
last_result_code: { type: DataTypes.STRING(64), allowNull: true },
|
||||
last_evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
|
||||
resolution_mutation_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
created_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updated_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
await queryInterface.createTable(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
dispatch_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: {
|
||||
model: APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
key: 'dispatch_id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
references: { model: PROJECT_TABLE, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
execution_version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
mutation_id: { type: DataTypes.STRING(64), allowNull: false },
|
||||
source: { type: DataTypes.STRING(24), allowNull: false },
|
||||
decision: { type: DataTypes.STRING(24), allowNull: false },
|
||||
evidence_digest: { type: DataTypes.STRING(64), allowNull: true },
|
||||
reason_code: { type: DataTypes.STRING(64), allowNull: false },
|
||||
resolved_by_type: { type: DataTypes.STRING(32), allowNull: true },
|
||||
resolved_by_id: { type: DataTypes.STRING(255), allowNull: true },
|
||||
resolved_at_ms: { type: DataTypes.BIGINT, allowNull: false },
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
for (const [table, field, minimum, maximum, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'execution_version',
|
||||
1,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_execution_version_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'version',
|
||||
0,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_version_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'finding_count',
|
||||
0,
|
||||
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
'approved_action_recovery_finding_count_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'execution_version',
|
||||
1,
|
||||
MAX_APPROVED_ACTION_RECOVERY_VERSION,
|
||||
'approved_action_recovery_resolution_execution_version_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.between]: [minimum, maximum] } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
for (const [table, field, values, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'status',
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_STATUSES,
|
||||
'approved_action_recovery_status_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'last_finding',
|
||||
APPROVED_ACTION_RECOVERY_FINDINGS,
|
||||
'approved_action_recovery_finding_value_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'source',
|
||||
APPROVED_ACTION_RECOVERY_SOURCES,
|
||||
'approved_action_recovery_resolution_source_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'decision',
|
||||
APPROVED_ACTION_RECOVERY_DECISIONS,
|
||||
'approved_action_recovery_resolution_decision_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: { [field]: { [Op.in]: values } },
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['lease_owner', 'lease_token', 'lease_expires_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ lease_owner: null, lease_token: null, lease_expires_at_ms: null },
|
||||
{
|
||||
lease_owner: { [Op.not]: null },
|
||||
lease_token: { [Op.not]: null },
|
||||
lease_expires_at_ms: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_lease_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['last_finding_mutation_id', 'last_finding', 'last_result_code'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
last_finding_mutation_id: null,
|
||||
last_finding: null,
|
||||
last_result_code: null,
|
||||
},
|
||||
{
|
||||
last_finding_mutation_id: { [Op.not]: null },
|
||||
last_finding: { [Op.not]: null },
|
||||
last_result_code: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_finding_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
for (const [table, field, name] of [
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
'last_evidence_digest',
|
||||
'approved_action_recovery_evidence_digest_check',
|
||||
],
|
||||
[
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
'evidence_digest',
|
||||
'approved_action_recovery_resolution_evidence_digest_check',
|
||||
],
|
||||
] as const) {
|
||||
await queryInterface.addConstraint(table, {
|
||||
fields: [field],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ [field]: null },
|
||||
queryInterface.sequelize.where(
|
||||
queryInterface.sequelize.fn(
|
||||
'length',
|
||||
queryInterface.sequelize.col(field),
|
||||
),
|
||||
64,
|
||||
),
|
||||
],
|
||||
},
|
||||
name,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['status', 'resolution_mutation_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ status: 'resolved', resolution_mutation_id: { [Op.not]: null } },
|
||||
{
|
||||
status: { [Op.not]: 'resolved' },
|
||||
resolution_mutation_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_mutation_tuple_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['created_at_ms', 'updated_at_ms'],
|
||||
type: 'check',
|
||||
where: {
|
||||
created_at_ms: { [Op.gte]: 0 },
|
||||
updated_at_ms: { [Op.gte]: { [Op.col]: 'created_at_ms' } },
|
||||
},
|
||||
name: 'approved_action_recovery_timestamps_check',
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['resolved_by_type', 'resolved_by_id'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ resolved_by_type: null, resolved_by_id: null },
|
||||
{
|
||||
resolved_by_type: 'user',
|
||||
resolved_by_id: { [Op.not]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_actor_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['source', 'decision', 'evidence_digest', 'resolved_by_type'],
|
||||
type: 'check',
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
source: 'automatic_evidence',
|
||||
decision: {
|
||||
[Op.in]: ['confirm_succeeded', 'confirm_failed'],
|
||||
},
|
||||
evidence_digest: { [Op.not]: null },
|
||||
resolved_by_type: null,
|
||||
},
|
||||
{ source: 'human', resolved_by_type: 'user' },
|
||||
],
|
||||
},
|
||||
name: 'approved_action_recovery_resolution_source_tuple_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addConstraint(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
{
|
||||
fields: ['resolved_at_ms'],
|
||||
type: 'check',
|
||||
where: { resolved_at_ms: { [Op.gte]: 0 } },
|
||||
name: 'approved_action_recovery_resolution_time_check',
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
['status', 'next_scan_at_ms', 'dispatch_id'],
|
||||
{ name: APPROVED_ACTION_RECOVERY_DUE_INDEX, transaction },
|
||||
);
|
||||
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['project_id', 'status', 'created_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_RECOVERY_PROJECT_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(APPROVED_ACTION_RECOVERY_CONTROL_TABLE, {
|
||||
fields: ['status', 'lease_expires_at_ms', 'dispatch_id'],
|
||||
name: APPROVED_ACTION_RECOVERY_LEASE_INDEX,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
['mutation_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_RESOLUTION_MUTATION_INDEX,
|
||||
unique: true,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
['project_id', 'resolved_at_ms', 'dispatch_id'],
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_RESOLUTION_PROJECT_INDEX,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO "${APPROVED_ACTION_RECOVERY_CONTROL_TABLE}"
|
||||
(dispatch_id, project_id, execution_version, status, version,
|
||||
next_scan_at_ms, lease_owner, lease_token, lease_expires_at_ms,
|
||||
finding_count, last_finding_mutation_id, last_finding,
|
||||
last_result_code, last_evidence_digest,
|
||||
resolution_mutation_id, created_at_ms, updated_at_ms)
|
||||
SELECT dispatch_id, project_id, version, 'armed', 0,
|
||||
lease_expires_at_ms, NULL, NULL, NULL,
|
||||
0, NULL, NULL, NULL, NULL, NULL, started_at_ms, started_at_ms
|
||||
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
||||
WHERE status = 'executing'`,
|
||||
{ transaction },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
export interface PostgresSchemaContractTable {
|
||||
readonly name: string;
|
||||
readonly columns: readonly string[];
|
||||
}
|
||||
|
||||
export interface PostgresSchemaContract {
|
||||
readonly schema: 'ql3';
|
||||
readonly contractName: 'control-core';
|
||||
readonly contractVersion: 2;
|
||||
readonly migrationId: 'pg-0003-run-retry-policy';
|
||||
readonly minimumServerMajor: 16;
|
||||
readonly maximumServerMajor: 18;
|
||||
readonly capabilities: Readonly<{
|
||||
run_core: 1;
|
||||
run_retry_policy: 1;
|
||||
}>;
|
||||
readonly tables: readonly PostgresSchemaContractTable[];
|
||||
readonly indexes: readonly string[];
|
||||
readonly checks: readonly string[];
|
||||
readonly foreignKeys: readonly string[];
|
||||
}
|
||||
|
||||
function table(
|
||||
name: string,
|
||||
columns: readonly string[],
|
||||
): PostgresSchemaContractTable {
|
||||
return Object.freeze({ name, columns: Object.freeze([...columns]) });
|
||||
}
|
||||
|
||||
export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
Object.freeze({
|
||||
schema: 'ql3',
|
||||
contractName: 'control-core',
|
||||
contractVersion: 2,
|
||||
migrationId: 'pg-0003-run-retry-policy',
|
||||
minimumServerMajor: 16,
|
||||
maximumServerMajor: 18,
|
||||
capabilities: Object.freeze({ run_core: 1, run_retry_policy: 1 }),
|
||||
tables: Object.freeze([
|
||||
table('schema_migrations', [
|
||||
'migration_id',
|
||||
'stream_id',
|
||||
'dialect',
|
||||
'checksum',
|
||||
'applied_at_ms',
|
||||
]),
|
||||
table('schema_capabilities', [
|
||||
'contract_name',
|
||||
'contract_version',
|
||||
'migration_id',
|
||||
'capabilities',
|
||||
'updated_at_ms',
|
||||
]),
|
||||
table('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',
|
||||
'cancel_requested_at_ms',
|
||||
'cancel_reason',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
]),
|
||||
table('run_attempts', [
|
||||
'id',
|
||||
'run_id',
|
||||
'step_run_id',
|
||||
'attempt',
|
||||
'status',
|
||||
'executor_type',
|
||||
'worker_id',
|
||||
'executor_handle',
|
||||
'pid',
|
||||
'log_artifact_id',
|
||||
'lease_token',
|
||||
'lease_expires_at_ms',
|
||||
'deadline_at_ms',
|
||||
'callback_token_hash',
|
||||
'callback_sequence',
|
||||
'created_at_ms',
|
||||
'started_at_ms',
|
||||
'finished_at_ms',
|
||||
'exit_code',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
]),
|
||||
table('run_events', [
|
||||
'id',
|
||||
'run_id',
|
||||
'sequence',
|
||||
'type',
|
||||
'dedupe_key',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'attempt_id',
|
||||
'step_run_id',
|
||||
'payload',
|
||||
'created_at_ms',
|
||||
]),
|
||||
table('run_retry_policies', [
|
||||
'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: Object.freeze([
|
||||
'schema_migrations_pkey',
|
||||
'schema_capabilities_pkey',
|
||||
'runs_pkey',
|
||||
'ql3_runs_project_idempotency_uidx',
|
||||
'ql3_runs_project_created_idx',
|
||||
'ql3_runs_task_created_idx',
|
||||
'ql3_runs_dispatch_candidates_idx',
|
||||
'run_attempts_pkey',
|
||||
'ql3_run_attempts_run_attempt_uidx',
|
||||
'ql3_run_attempts_dispatch_candidates_idx',
|
||||
'ql3_run_attempts_lease_idx',
|
||||
'run_events_pkey',
|
||||
'ql3_run_events_run_sequence_uidx',
|
||||
'ql3_run_events_run_dedupe_uidx',
|
||||
'ql3_run_events_run_created_idx',
|
||||
'run_retry_policies_pkey',
|
||||
'ql3_run_retry_policies_due_idx',
|
||||
'ql3_runs_lost_retry_idx',
|
||||
]),
|
||||
checks: Object.freeze([
|
||||
'ql3_schema_migrations_dialect_check',
|
||||
'ql3_schema_migrations_checksum_check',
|
||||
'ql3_schema_migrations_applied_at_check',
|
||||
'ql3_schema_capabilities_version_check',
|
||||
'ql3_schema_capabilities_payload_check',
|
||||
'ql3_schema_capabilities_updated_at_check',
|
||||
'ql3_runs_legacy_cron_id_check',
|
||||
'ql3_runs_execution_owner_check',
|
||||
'ql3_runs_scheduled_for_check',
|
||||
'ql3_runs_status_check',
|
||||
'ql3_runs_version_check',
|
||||
'ql3_runs_event_sequence_check',
|
||||
'ql3_runs_created_at_check',
|
||||
'ql3_runs_queued_at_check',
|
||||
'ql3_runs_started_at_check',
|
||||
'ql3_runs_finished_at_check',
|
||||
'ql3_runs_cancel_requested_at_check',
|
||||
'ql3_runs_cancel_reason_check',
|
||||
'ql3_run_attempts_attempt_check',
|
||||
'ql3_run_attempts_status_check',
|
||||
'ql3_run_attempts_pid_check',
|
||||
'ql3_run_attempts_lease_expiry_check',
|
||||
'ql3_run_attempts_deadline_check',
|
||||
'ql3_run_attempts_callback_sequence_check',
|
||||
'ql3_run_attempts_created_at_check',
|
||||
'ql3_run_attempts_started_at_check',
|
||||
'ql3_run_attempts_finished_at_check',
|
||||
'ql3_run_events_sequence_check',
|
||||
'ql3_run_events_actor_type_check',
|
||||
'ql3_run_events_payload_check',
|
||||
'ql3_run_events_created_at_check',
|
||||
'ql3_run_retry_policies_max_attempts_check',
|
||||
'ql3_run_retry_policies_safety_check',
|
||||
'ql3_run_retry_policies_backoff_base_check',
|
||||
'ql3_run_retry_policies_backoff_max_check',
|
||||
'ql3_run_retry_policies_next_attempt_check',
|
||||
'ql3_run_retry_policies_version_check',
|
||||
'ql3_run_retry_policies_created_at_check',
|
||||
'ql3_run_retry_policies_updated_at_check',
|
||||
]),
|
||||
foreignKeys: Object.freeze([
|
||||
'ql3_schema_capabilities_migration_fk',
|
||||
'ql3_runs_parent_fk',
|
||||
'ql3_runs_retry_of_fk',
|
||||
'ql3_run_attempts_run_fk',
|
||||
'ql3_run_events_run_fk',
|
||||
'ql3_run_events_attempt_fk',
|
||||
'ql3_run_retry_policies_run_fk',
|
||||
]),
|
||||
});
|
||||
@@ -1,470 +0,0 @@
|
||||
import {
|
||||
readPostgresMigrationHistory,
|
||||
type PostgresMigrationQueryable,
|
||||
} from '../adapters/postgresMigrationStreamStore';
|
||||
import { auditMigrationStreamHistory } from '../core/migrationStream';
|
||||
import { postgresqlMainMigrationStream } from '.';
|
||||
import {
|
||||
postgresqlControlSchemaContract,
|
||||
type PostgresSchemaContract,
|
||||
} from './schemaContract';
|
||||
|
||||
export const POSTGRES_SCHEMA_READINESS_ERROR_CODES = [
|
||||
'server_version_unsupported',
|
||||
'server_not_writable_primary',
|
||||
'migration_history_invalid',
|
||||
'capability_invalid',
|
||||
'schema_contract_invalid',
|
||||
'runtime_role_invalid',
|
||||
] as const;
|
||||
|
||||
export type PostgresSchemaReadinessErrorCode =
|
||||
(typeof POSTGRES_SCHEMA_READINESS_ERROR_CODES)[number];
|
||||
|
||||
export class PostgresSchemaReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: PostgresSchemaReadinessErrorCode,
|
||||
readonly facts: readonly string[] = [],
|
||||
) {
|
||||
super(`PostgreSQL schema is not ready: ${code}`);
|
||||
this.name = 'PostgresSchemaReadinessError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostgresSchemaReadinessReport {
|
||||
readonly ready: true;
|
||||
readonly writablePrimary: true;
|
||||
readonly serverVersionNum: number;
|
||||
readonly serverMajor: number;
|
||||
readonly currentUser: string;
|
||||
readonly contractName: string;
|
||||
readonly contractVersion: number;
|
||||
readonly migrationIds: readonly string[];
|
||||
}
|
||||
|
||||
interface ServerRow extends Record<string, unknown> {
|
||||
serverVersionNum: unknown;
|
||||
currentUser: unknown;
|
||||
inRecovery: unknown;
|
||||
transactionReadOnly: unknown;
|
||||
}
|
||||
|
||||
interface CapabilityRow extends Record<string, unknown> {
|
||||
contractName: unknown;
|
||||
contractVersion: unknown;
|
||||
migrationId: unknown;
|
||||
capabilities: unknown;
|
||||
}
|
||||
|
||||
interface ColumnRow extends Record<string, unknown> {
|
||||
tableName: unknown;
|
||||
columnName: unknown;
|
||||
}
|
||||
|
||||
interface IndexRow extends Record<string, unknown> {
|
||||
indexName: unknown;
|
||||
}
|
||||
|
||||
interface ConstraintRow extends Record<string, unknown> {
|
||||
constraintName: unknown;
|
||||
constraintType: unknown;
|
||||
}
|
||||
|
||||
interface SchemaPrivilegeRow extends Record<string, unknown> {
|
||||
schemaUsage: unknown;
|
||||
schemaCreate: unknown;
|
||||
}
|
||||
|
||||
interface TablePrivilegeRow extends Record<string, unknown> {
|
||||
tableName: unknown;
|
||||
selectAllowed: unknown;
|
||||
insertAllowed: unknown;
|
||||
updateAllowed: unknown;
|
||||
deleteAllowed: unknown;
|
||||
isOwner: unknown;
|
||||
}
|
||||
|
||||
const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({
|
||||
schema_migrations: Object.freeze({
|
||||
select: true,
|
||||
insert: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
schema_capabilities: Object.freeze({
|
||||
select: true,
|
||||
insert: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
runs: Object.freeze({
|
||||
select: true,
|
||||
insert: true,
|
||||
update: true,
|
||||
delete: false,
|
||||
}),
|
||||
run_attempts: Object.freeze({
|
||||
select: true,
|
||||
insert: true,
|
||||
update: true,
|
||||
delete: false,
|
||||
}),
|
||||
run_events: Object.freeze({
|
||||
select: true,
|
||||
insert: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
run_retry_policies: Object.freeze({
|
||||
select: true,
|
||||
insert: true,
|
||||
update: true,
|
||||
delete: false,
|
||||
}),
|
||||
});
|
||||
|
||||
function safeInteger(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sorted(values: Iterable<string>): string[] {
|
||||
return [...values].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function exactJsonObject(
|
||||
actual: unknown,
|
||||
expected: Readonly<Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) {
|
||||
return false;
|
||||
}
|
||||
const actualObject = actual as Record<string, unknown>;
|
||||
const actualKeys = sorted(Object.keys(actualObject));
|
||||
const expectedKeys = sorted(Object.keys(expected));
|
||||
return (
|
||||
actualKeys.length === expectedKeys.length &&
|
||||
actualKeys.every(
|
||||
(key, index) =>
|
||||
key === expectedKeys[index] && actualObject[key] === expected[key],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function readServer(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
contract: PostgresSchemaContract,
|
||||
): Promise<{
|
||||
writablePrimary: true;
|
||||
serverVersionNum: number;
|
||||
serverMajor: number;
|
||||
currentUser: string;
|
||||
}> {
|
||||
const result = await queryable.query<ServerRow>(
|
||||
`
|
||||
SELECT
|
||||
current_setting('server_version_num') AS "serverVersionNum",
|
||||
current_user AS "currentUser",
|
||||
pg_is_in_recovery() AS "inRecovery",
|
||||
current_setting('transaction_read_only') AS "transactionReadOnly"
|
||||
`.trim(),
|
||||
);
|
||||
const row = result.rows[0];
|
||||
const serverVersionNum = safeInteger(row?.serverVersionNum);
|
||||
const currentUser = row?.currentUser;
|
||||
const inRecovery = row?.inRecovery;
|
||||
const transactionReadOnly = row?.transactionReadOnly;
|
||||
const serverMajor =
|
||||
serverVersionNum === null ? null : Math.floor(serverVersionNum / 10_000);
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
serverVersionNum === null ||
|
||||
serverMajor === null ||
|
||||
serverMajor < contract.minimumServerMajor ||
|
||||
serverMajor > contract.maximumServerMajor ||
|
||||
typeof currentUser !== 'string' ||
|
||||
currentUser.length === 0
|
||||
) {
|
||||
throw new PostgresSchemaReadinessError('server_version_unsupported', [
|
||||
String(serverVersionNum ?? 'invalid'),
|
||||
]);
|
||||
}
|
||||
if (inRecovery !== false || transactionReadOnly !== 'off') {
|
||||
throw new PostgresSchemaReadinessError('server_not_writable_primary', [
|
||||
`in-recovery:${String(inRecovery)}`,
|
||||
`transaction-read-only:${String(transactionReadOnly)}`,
|
||||
]);
|
||||
}
|
||||
return {
|
||||
writablePrimary: true,
|
||||
serverVersionNum,
|
||||
serverMajor,
|
||||
currentUser,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertHistory(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
): Promise<readonly string[]> {
|
||||
const history = await readPostgresMigrationHistory(queryable);
|
||||
try {
|
||||
auditMigrationStreamHistory(history, postgresqlMainMigrationStream);
|
||||
return Object.freeze(history.map(({ migrationId }) => migrationId));
|
||||
} catch (error) {
|
||||
throw new PostgresSchemaReadinessError('migration_history_invalid', [
|
||||
error instanceof Error ? error.name : 'UnknownError',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCapability(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
contract: PostgresSchemaContract,
|
||||
): Promise<void> {
|
||||
const result = await queryable.query<CapabilityRow>(
|
||||
`
|
||||
SELECT
|
||||
contract_name AS "contractName",
|
||||
contract_version AS "contractVersion",
|
||||
migration_id AS "migrationId",
|
||||
capabilities
|
||||
FROM "${contract.schema}"."schema_capabilities"
|
||||
WHERE contract_name = $1
|
||||
`.trim(),
|
||||
[contract.contractName],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!row ||
|
||||
row.contractName !== contract.contractName ||
|
||||
safeInteger(row.contractVersion) !== contract.contractVersion ||
|
||||
row.migrationId !== contract.migrationId ||
|
||||
!exactJsonObject(row.capabilities, contract.capabilities)
|
||||
) {
|
||||
throw new PostgresSchemaReadinessError('capability_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSchemaContract(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
contract: PostgresSchemaContract,
|
||||
): Promise<void> {
|
||||
const [columnsResult, indexesResult, constraintsResult] = await Promise.all([
|
||||
queryable.query<ColumnRow>(
|
||||
`
|
||||
SELECT table_name AS "tableName", column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
ORDER BY table_name, ordinal_position
|
||||
`.trim(),
|
||||
[contract.schema],
|
||||
),
|
||||
queryable.query<IndexRow>(
|
||||
`
|
||||
SELECT indexname AS "indexName"
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = $1
|
||||
ORDER BY indexname
|
||||
`.trim(),
|
||||
[contract.schema],
|
||||
),
|
||||
queryable.query<ConstraintRow>(
|
||||
`
|
||||
SELECT
|
||||
constraints.conname AS "constraintName",
|
||||
CASE constraints.contype
|
||||
WHEN 'c' THEN 'check'
|
||||
WHEN 'f' THEN 'foreign_key'
|
||||
END AS "constraintType"
|
||||
FROM pg_constraint constraints
|
||||
JOIN pg_class tables ON tables.oid = constraints.conrelid
|
||||
JOIN pg_namespace schemas ON schemas.oid = tables.relnamespace
|
||||
WHERE schemas.nspname = $1
|
||||
AND constraints.contype IN ('c', 'f')
|
||||
ORDER BY constraints.contype, constraints.conname
|
||||
`.trim(),
|
||||
[contract.schema],
|
||||
),
|
||||
]);
|
||||
const actualTables = new Map<string, Set<string>>();
|
||||
for (const row of columnsResult.rows) {
|
||||
if (
|
||||
typeof row.tableName !== 'string' ||
|
||||
typeof row.columnName !== 'string'
|
||||
) {
|
||||
throw new PostgresSchemaReadinessError('schema_contract_invalid');
|
||||
}
|
||||
const columns = actualTables.get(row.tableName) ?? new Set<string>();
|
||||
columns.add(row.columnName);
|
||||
actualTables.set(row.tableName, columns);
|
||||
}
|
||||
const expectedTables = new Map(
|
||||
contract.tables.map((table) => [table.name, new Set(table.columns)]),
|
||||
);
|
||||
const findings: string[] = [];
|
||||
for (const [tableName, expectedColumns] of expectedTables) {
|
||||
const actualColumns = actualTables.get(tableName);
|
||||
if (!actualColumns) {
|
||||
findings.push(`missing-table:${tableName}`);
|
||||
continue;
|
||||
}
|
||||
for (const column of expectedColumns) {
|
||||
if (!actualColumns.has(column)) {
|
||||
findings.push(`missing-column:${tableName}.${column}`);
|
||||
}
|
||||
}
|
||||
for (const column of actualColumns) {
|
||||
if (!expectedColumns.has(column)) {
|
||||
findings.push(`unknown-column:${tableName}.${column}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tableName of actualTables.keys()) {
|
||||
if (!expectedTables.has(tableName))
|
||||
findings.push(`unknown-table:${tableName}`);
|
||||
}
|
||||
const actualIndexes = new Set<string>();
|
||||
for (const row of indexesResult.rows) {
|
||||
if (typeof row.indexName !== 'string') {
|
||||
throw new PostgresSchemaReadinessError('schema_contract_invalid');
|
||||
}
|
||||
actualIndexes.add(row.indexName);
|
||||
}
|
||||
const expectedIndexes = new Set(contract.indexes);
|
||||
for (const index of expectedIndexes) {
|
||||
if (!actualIndexes.has(index)) findings.push(`missing-index:${index}`);
|
||||
}
|
||||
for (const index of actualIndexes) {
|
||||
if (!expectedIndexes.has(index)) findings.push(`unknown-index:${index}`);
|
||||
}
|
||||
const actualChecks = new Set<string>();
|
||||
const actualForeignKeys = new Set<string>();
|
||||
for (const row of constraintsResult.rows) {
|
||||
if (
|
||||
typeof row.constraintName !== 'string' ||
|
||||
(row.constraintType !== 'check' && row.constraintType !== 'foreign_key')
|
||||
) {
|
||||
throw new PostgresSchemaReadinessError('schema_contract_invalid');
|
||||
}
|
||||
const target =
|
||||
row.constraintType === 'check' ? actualChecks : actualForeignKeys;
|
||||
target.add(row.constraintName);
|
||||
}
|
||||
for (const check of contract.checks) {
|
||||
if (!actualChecks.has(check)) findings.push(`missing-check:${check}`);
|
||||
}
|
||||
for (const check of actualChecks) {
|
||||
if (!contract.checks.includes(check))
|
||||
findings.push(`unknown-check:${check}`);
|
||||
}
|
||||
for (const foreignKey of contract.foreignKeys) {
|
||||
if (!actualForeignKeys.has(foreignKey)) {
|
||||
findings.push(`missing-foreign-key:${foreignKey}`);
|
||||
}
|
||||
}
|
||||
for (const foreignKey of actualForeignKeys) {
|
||||
if (!contract.foreignKeys.includes(foreignKey)) {
|
||||
findings.push(`unknown-foreign-key:${foreignKey}`);
|
||||
}
|
||||
}
|
||||
if (findings.length > 0) {
|
||||
throw new PostgresSchemaReadinessError(
|
||||
'schema_contract_invalid',
|
||||
sorted(findings),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRuntimeRole(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
contract: PostgresSchemaContract,
|
||||
): Promise<void> {
|
||||
const schemaResult = await queryable.query<SchemaPrivilegeRow>(
|
||||
`
|
||||
SELECT
|
||||
has_schema_privilege(current_user, $1, 'USAGE') AS "schemaUsage",
|
||||
has_schema_privilege(current_user, $1, 'CREATE') AS "schemaCreate"
|
||||
`.trim(),
|
||||
[contract.schema],
|
||||
);
|
||||
const schema = schemaResult.rows[0];
|
||||
const tableNames = Object.keys(REQUIRED_RUNTIME_PRIVILEGES);
|
||||
const tableResult = await queryable.query<TablePrivilegeRow>(
|
||||
`
|
||||
SELECT
|
||||
requested.table_name AS "tableName",
|
||||
has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'SELECT') AS "selectAllowed",
|
||||
has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'INSERT') AS "insertAllowed",
|
||||
has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'UPDATE') AS "updateAllowed",
|
||||
has_table_privilege(current_user, format('%I.%I', $1, requested.table_name), 'DELETE') AS "deleteAllowed",
|
||||
pg_get_userbyid(classes.relowner) = current_user AS "isOwner"
|
||||
FROM unnest($2::text[]) AS requested(table_name)
|
||||
JOIN pg_namespace namespaces ON namespaces.nspname = $1
|
||||
JOIN pg_class classes
|
||||
ON classes.relnamespace = namespaces.oid
|
||||
AND classes.relname = requested.table_name
|
||||
ORDER BY requested.table_name
|
||||
`.trim(),
|
||||
[contract.schema, tableNames],
|
||||
);
|
||||
const findings: string[] = [];
|
||||
if (
|
||||
schemaResult.rows.length !== 1 ||
|
||||
schema?.schemaUsage !== true ||
|
||||
schema?.schemaCreate !== false
|
||||
) {
|
||||
findings.push('schema-privileges');
|
||||
}
|
||||
const privilegesByTable = new Map(
|
||||
tableResult.rows.map((row) => [row.tableName, row]),
|
||||
);
|
||||
for (const tableName of tableNames) {
|
||||
const expected =
|
||||
REQUIRED_RUNTIME_PRIVILEGES[
|
||||
tableName as keyof typeof REQUIRED_RUNTIME_PRIVILEGES
|
||||
];
|
||||
const actual = privilegesByTable.get(tableName);
|
||||
if (
|
||||
!actual ||
|
||||
actual.selectAllowed !== expected.select ||
|
||||
actual.insertAllowed !== expected.insert ||
|
||||
actual.updateAllowed !== expected.update ||
|
||||
actual.deleteAllowed !== expected.delete ||
|
||||
actual.isOwner !== false
|
||||
) {
|
||||
findings.push(`table-privileges:${tableName}`);
|
||||
}
|
||||
}
|
||||
if (privilegesByTable.size !== tableNames.length) {
|
||||
findings.push('table-privilege-row-count');
|
||||
}
|
||||
if (findings.length > 0) {
|
||||
throw new PostgresSchemaReadinessError(
|
||||
'runtime_role_invalid',
|
||||
sorted(findings),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPostgresSchemaReady(
|
||||
queryable: PostgresMigrationQueryable,
|
||||
contract: PostgresSchemaContract = postgresqlControlSchemaContract,
|
||||
): Promise<PostgresSchemaReadinessReport> {
|
||||
const server = await readServer(queryable, contract);
|
||||
const migrationIds = await assertHistory(queryable);
|
||||
await assertCapability(queryable, contract);
|
||||
await assertSchemaContract(queryable, contract);
|
||||
await assertRuntimeRole(queryable, contract);
|
||||
return Object.freeze({
|
||||
ready: true,
|
||||
...server,
|
||||
contractName: contract.contractName,
|
||||
contractVersion: contract.contractVersion,
|
||||
migrationIds,
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import type { MigrationStreamStep } from '../core/migrationStream';
|
||||
import type { PostgresMigrationContext } from '../adapters/postgresMigrationStreamStore';
|
||||
|
||||
export interface PostgresSqlMigrationDefinition {
|
||||
readonly id: string;
|
||||
readonly statements: readonly string[];
|
||||
}
|
||||
|
||||
export function checksumPostgresStatements(
|
||||
statements: readonly string[],
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
statements,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function definePostgresSqlMigration(
|
||||
definition: PostgresSqlMigrationDefinition,
|
||||
): MigrationStreamStep<PostgresMigrationContext> {
|
||||
const statements = Object.freeze([...definition.statements]);
|
||||
return Object.freeze({
|
||||
id: definition.id,
|
||||
checksum: checksumPostgresStatements(statements),
|
||||
async up(context: PostgresMigrationContext) {
|
||||
for (const statement of statements) await context.query(statement);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { ModelStatic, Sequelize } from 'sequelize';
|
||||
import Logger from '../loaders/logger';
|
||||
import {
|
||||
SchemaMigrationInstance,
|
||||
SchemaMigrationModel,
|
||||
} from '../data/schemaMigration';
|
||||
import { sequelize } from '../data';
|
||||
import { migrations as registeredMigrations } from '.';
|
||||
import {
|
||||
SQLITE_MAIN_MIGRATION_STREAM_ID,
|
||||
SequelizeSqliteMigrationStreamStore,
|
||||
} from './adapters/sequelizeSqliteMigrationStreamStore';
|
||||
import {
|
||||
runMigrationStream,
|
||||
type MigrationStreamStore,
|
||||
} from './core/migrationStream';
|
||||
import type { Migration, MigrationContext } from './types';
|
||||
|
||||
interface MigrationLogger {
|
||||
info(message: string): unknown;
|
||||
}
|
||||
|
||||
export interface RunMigrationsOptions {
|
||||
database?: Sequelize;
|
||||
migrationModel?: ModelStatic<SchemaMigrationInstance>;
|
||||
migrations?: Migration[];
|
||||
logger?: MigrationLogger;
|
||||
}
|
||||
|
||||
function validateMigrations(migrations: Migration[]) {
|
||||
const ids = new Set<string>();
|
||||
for (const migration of migrations) {
|
||||
if (ids.has(migration.id)) {
|
||||
throw new Error(`Duplicate migration id: ${migration.id}`);
|
||||
}
|
||||
ids.add(migration.id);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeHistoryToCustomMigrations(
|
||||
store: MigrationStreamStore<MigrationContext>,
|
||||
migrations: readonly Migration[],
|
||||
): MigrationStreamStore<MigrationContext> {
|
||||
const ids = new Set(migrations.map(({ id }) => id));
|
||||
return {
|
||||
ensureHistory: () => store.ensureHistory(),
|
||||
listAll: async () =>
|
||||
(await store.listAll()).filter(({ migrationId }) => ids.has(migrationId)),
|
||||
findById: (migrationId) => store.findById(migrationId),
|
||||
transaction: (work) => store.transaction(work),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMigrations(
|
||||
options: RunMigrationsOptions = {},
|
||||
): Promise<void> {
|
||||
const database = options.database || sequelize;
|
||||
const migrationModel = options.migrationModel || SchemaMigrationModel;
|
||||
const migrations = options.migrations || registeredMigrations;
|
||||
const logger = options.logger || Logger;
|
||||
|
||||
validateMigrations(migrations);
|
||||
const store = new SequelizeSqliteMigrationStreamStore(
|
||||
database,
|
||||
migrationModel,
|
||||
);
|
||||
await runMigrationStream({
|
||||
stream: {
|
||||
id: SQLITE_MAIN_MIGRATION_STREAM_ID,
|
||||
dialect: 'sqlite',
|
||||
migrationIdScheme: 'sqlite-numbered',
|
||||
checksumScheme: 'legacy-opaque',
|
||||
migrations,
|
||||
},
|
||||
store:
|
||||
options.migrations === undefined
|
||||
? store
|
||||
: scopeHistoryToCustomMigrations(store, migrations),
|
||||
clock: Date.now,
|
||||
logger: {
|
||||
info(message) {
|
||||
logger.info(
|
||||
message.replace(
|
||||
`[migration:${SQLITE_MAIN_MIGRATION_STREAM_ID}]`,
|
||||
'[migration]',
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
SqliteSchemaOwnershipManifest,
|
||||
sqliteSchemaOwnership,
|
||||
} from './schemaOwnership';
|
||||
|
||||
export interface SqliteSchemaSnapshotTable {
|
||||
name: string;
|
||||
columns: readonly string[];
|
||||
indexes: readonly string[];
|
||||
}
|
||||
|
||||
export interface SqliteSchemaSnapshot {
|
||||
tables: readonly SqliteSchemaSnapshotTable[];
|
||||
migrationIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface SchemaColumnFinding {
|
||||
table: string;
|
||||
column: string;
|
||||
}
|
||||
|
||||
export interface SqliteSchemaAuditReport {
|
||||
compatible: boolean;
|
||||
driftDetected: boolean;
|
||||
missingTables: string[];
|
||||
missingColumns: SchemaColumnFinding[];
|
||||
missingIndexes: string[];
|
||||
missingMigrationIds: string[];
|
||||
unknownTables: string[];
|
||||
unknownColumns: SchemaColumnFinding[];
|
||||
unknownIndexes: string[];
|
||||
extraMigrationIds: string[];
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function sortColumns(values: SchemaColumnFinding[]): SchemaColumnFinding[] {
|
||||
return values.sort(
|
||||
(left, right) =>
|
||||
left.table.localeCompare(right.table) ||
|
||||
left.column.localeCompare(right.column),
|
||||
);
|
||||
}
|
||||
|
||||
export function auditSqliteSchema(
|
||||
snapshot: SqliteSchemaSnapshot,
|
||||
manifest: SqliteSchemaOwnershipManifest = sqliteSchemaOwnership,
|
||||
): SqliteSchemaAuditReport {
|
||||
const actualTables = new Map(
|
||||
snapshot.tables.map((table) => [
|
||||
table.name,
|
||||
{
|
||||
columns: new Set(table.columns),
|
||||
indexes: new Set(table.indexes),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const ownedTables = new Map(
|
||||
manifest.tables.map((table) => [table.name, table]),
|
||||
);
|
||||
const actualIndexes = new Set(
|
||||
snapshot.tables.flatMap((table) => [...table.indexes]),
|
||||
);
|
||||
const ownedIndexes = new Set(manifest.indexes.map((index) => index.name));
|
||||
const actualMigrations = new Set(snapshot.migrationIds);
|
||||
const ownedMigrations = new Set(manifest.migrationIds);
|
||||
const missingTables: string[] = [];
|
||||
const missingColumns: SchemaColumnFinding[] = [];
|
||||
const unknownColumns: SchemaColumnFinding[] = [];
|
||||
|
||||
for (const table of manifest.tables) {
|
||||
const actual = actualTables.get(table.name);
|
||||
if (!actual) {
|
||||
if (table.mode === 'unmanaged-legacy') continue;
|
||||
missingTables.push(table.name);
|
||||
continue;
|
||||
}
|
||||
for (const column of table.requiredColumns) {
|
||||
if (!actual.columns.has(column)) {
|
||||
missingColumns.push({ table: table.name, column });
|
||||
}
|
||||
}
|
||||
if (table.mode === 'full') {
|
||||
const ownedColumns = new Set(table.requiredColumns);
|
||||
for (const column of actual.columns) {
|
||||
if (!ownedColumns.has(column)) {
|
||||
unknownColumns.push({ table: table.name, column });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingIndexes = manifest.indexes
|
||||
.map((index) => index.name)
|
||||
.filter((index) => !actualIndexes.has(index));
|
||||
const missingMigrationIds = manifest.migrationIds.filter(
|
||||
(migrationId) => !actualMigrations.has(migrationId),
|
||||
);
|
||||
const unknownTables = snapshot.tables
|
||||
.map((table) => table.name)
|
||||
.filter(
|
||||
(table) => !table.startsWith('sqlite_') && !ownedTables.has(table),
|
||||
);
|
||||
const reportableIndexes = new Set(
|
||||
snapshot.tables.flatMap((table) => {
|
||||
const ownership = ownedTables.get(table.name);
|
||||
return !ownership || ownership.mode === 'full' ? [...table.indexes] : [];
|
||||
}),
|
||||
);
|
||||
const unknownIndexes = [...reportableIndexes].filter(
|
||||
(index) => !index.startsWith('sqlite_autoindex_') && !ownedIndexes.has(index),
|
||||
);
|
||||
const extraMigrationIds = snapshot.migrationIds.filter(
|
||||
(migrationId) => !ownedMigrations.has(migrationId),
|
||||
);
|
||||
const compatible =
|
||||
missingTables.length === 0 &&
|
||||
missingColumns.length === 0 &&
|
||||
missingIndexes.length === 0 &&
|
||||
missingMigrationIds.length === 0;
|
||||
const driftDetected =
|
||||
unknownTables.length > 0 ||
|
||||
unknownColumns.length > 0 ||
|
||||
unknownIndexes.length > 0 ||
|
||||
extraMigrationIds.length > 0;
|
||||
|
||||
return {
|
||||
compatible,
|
||||
driftDetected,
|
||||
missingTables: sortedUnique(missingTables),
|
||||
missingColumns: sortColumns(missingColumns),
|
||||
missingIndexes: sortedUnique(missingIndexes),
|
||||
missingMigrationIds: sortedUnique(missingMigrationIds),
|
||||
unknownTables: sortedUnique(unknownTables),
|
||||
unknownColumns: sortColumns(unknownColumns),
|
||||
unknownIndexes: sortedUnique(unknownIndexes),
|
||||
extraMigrationIds: sortedUnique(extraMigrationIds),
|
||||
};
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { legacyColumnOwnership } from './0001-legacy-columns';
|
||||
import {
|
||||
RUN_ATTEMPT_TABLE,
|
||||
RUN_EVENT_TABLE,
|
||||
RUN_TABLE,
|
||||
runSchemaManifest,
|
||||
} from './0002-run-schema';
|
||||
import {
|
||||
RUNNING_INSTANCE_TABLE,
|
||||
runningInstanceRunReferenceManifest,
|
||||
} from './0003-running-instance-run-reference';
|
||||
import { runCancellationRequestManifest } from './0004-run-cancellation-request';
|
||||
import {
|
||||
RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
runCancellationDispatchManifest,
|
||||
} from './0005-run-cancellation-dispatch';
|
||||
import { runAttemptDeadlineManifest } from './0006-run-attempt-deadline';
|
||||
import {
|
||||
COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
completionReceiptJournalManifest,
|
||||
} from './0007-completion-receipt-journal';
|
||||
import {
|
||||
WORKER_REGISTRY_TABLE,
|
||||
workerRegistryManifest,
|
||||
} from './0008-worker-registry';
|
||||
import {
|
||||
RUN_DISPATCH_LEASE_TABLE,
|
||||
runDispatchLeaseManifest,
|
||||
} from './0009-run-dispatch-lease';
|
||||
import { runDispatchCandidateManifest } from './0010-run-dispatch-candidates';
|
||||
import {
|
||||
RUN_RETRY_POLICY_TABLE,
|
||||
runRetryPolicyManifest,
|
||||
} from './0011-run-retry-policy';
|
||||
import {
|
||||
TASK_EXECUTION_REVISION_TABLE,
|
||||
taskExecutionRevisionManifest,
|
||||
} from './0012-task-execution-revisions';
|
||||
import {
|
||||
LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
||||
localExecutionContextRecipeManifest,
|
||||
} from './0013-local-execution-context-recipes';
|
||||
import {
|
||||
LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
localSecretEnvelopeManifest,
|
||||
} from './0014-local-secret-envelopes';
|
||||
import {
|
||||
LOCAL_ARTIFACT_RETENTION_TABLE,
|
||||
localArtifactRetentionManifest,
|
||||
} from './0015-local-artifact-retention';
|
||||
import {
|
||||
LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
localArtifactMaintenanceCursorManifest,
|
||||
} from './0016-local-artifact-maintenance-cursor';
|
||||
import {
|
||||
PROJECT_ROLE_BINDING_TABLE,
|
||||
PROJECT_TABLE,
|
||||
projectPolicyManifest,
|
||||
} from './0017-project-policy';
|
||||
import {
|
||||
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
projectOwnerBootstrapManifest,
|
||||
} from './0018-project-owner-bootstrap';
|
||||
import {
|
||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
||||
IDENTITY_SUBJECT_TABLE,
|
||||
identityDirectoryManifest,
|
||||
} from './0019-identity-directory';
|
||||
import {
|
||||
APPROVAL_REQUEST_TABLE,
|
||||
APPROVED_ACTION_DISPATCH_TABLE,
|
||||
approvalRequestManifest,
|
||||
} from './0020-approval-requests';
|
||||
import {
|
||||
APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
approvedActionDispatchExecutionManifest,
|
||||
} from './0021-approved-action-dispatch-executions';
|
||||
import {
|
||||
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
approvedActionRecoveryManifest,
|
||||
} from './0022-approved-action-recovery';
|
||||
import {
|
||||
APPROVED_RUN_ACTION_RECEIPT_TABLE,
|
||||
approvedRunActionReceiptManifest,
|
||||
} from './0023-approved-run-action-receipts';
|
||||
import {
|
||||
APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
|
||||
approvedActionRecoveryAuthorizationManifest,
|
||||
} from './0024-approved-action-recovery-authorization';
|
||||
|
||||
export type SchemaOwnershipMode = 'full' | 'extension' | 'unmanaged-legacy';
|
||||
|
||||
export interface SqliteTableOwnership {
|
||||
name: string;
|
||||
mode: SchemaOwnershipMode;
|
||||
requiredColumns: readonly string[];
|
||||
}
|
||||
|
||||
export interface SqliteIndexOwnership {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SqliteSchemaOwnershipManifest {
|
||||
version: 1;
|
||||
database: 'database.sqlite';
|
||||
migrationIds: readonly string[];
|
||||
tables: readonly SqliteTableOwnership[];
|
||||
indexes: readonly SqliteIndexOwnership[];
|
||||
constraints: readonly string[];
|
||||
unknownObjectPolicy: 'preserve-and-report';
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function indexName(definition: string): string {
|
||||
const boundary = definition.indexOf('(');
|
||||
return boundary === -1 ? definition : definition.slice(0, boundary);
|
||||
}
|
||||
|
||||
function legacyColumns(table: string): string[] {
|
||||
return legacyColumnOwnership
|
||||
.filter((definition) => definition.table === table)
|
||||
.map((definition) => definition.column);
|
||||
}
|
||||
|
||||
export const sqliteSchemaOwnership: SqliteSchemaOwnershipManifest = {
|
||||
version: 1,
|
||||
database: 'database.sqlite',
|
||||
migrationIds: [
|
||||
'0001-legacy-columns',
|
||||
'0002-run-schema',
|
||||
'0003-running-instance-run-reference',
|
||||
'0004-run-cancellation-request',
|
||||
'0005-run-cancellation-dispatch',
|
||||
'0006-run-attempt-deadline',
|
||||
'0007-completion-receipt-journal',
|
||||
'0008-worker-registry',
|
||||
'0009-run-dispatch-lease',
|
||||
'0010-run-dispatch-candidates',
|
||||
'0011-run-retry-policy',
|
||||
'0012-task-execution-revisions',
|
||||
'0013-local-execution-context-recipes',
|
||||
'0014-local-secret-envelopes',
|
||||
'0015-local-artifact-retention',
|
||||
'0016-local-artifact-maintenance-cursor',
|
||||
'0017-project-policy',
|
||||
'0018-project-owner-bootstrap',
|
||||
'0019-identity-directory',
|
||||
'0020-approval-requests',
|
||||
'0021-approved-action-dispatch-executions',
|
||||
'0022-approved-action-recovery',
|
||||
'0023-approved-run-action-receipts',
|
||||
'0024-approved-action-recovery-authorization',
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
name: 'SchemaMigrations',
|
||||
mode: 'full',
|
||||
requiredColumns: ['id', 'checksum', 'applied_at'],
|
||||
},
|
||||
{
|
||||
name: 'Apps',
|
||||
mode: 'unmanaged-legacy',
|
||||
requiredColumns: [],
|
||||
},
|
||||
{
|
||||
name: 'Auths',
|
||||
mode: 'unmanaged-legacy',
|
||||
requiredColumns: [],
|
||||
},
|
||||
{
|
||||
name: 'CrontabStats',
|
||||
mode: 'unmanaged-legacy',
|
||||
requiredColumns: [],
|
||||
},
|
||||
{
|
||||
name: 'Dependences',
|
||||
mode: 'unmanaged-legacy',
|
||||
requiredColumns: [],
|
||||
},
|
||||
{
|
||||
name: 'CrontabViews',
|
||||
mode: 'extension',
|
||||
requiredColumns: legacyColumns('CrontabViews'),
|
||||
},
|
||||
{
|
||||
name: 'Subscriptions',
|
||||
mode: 'extension',
|
||||
requiredColumns: legacyColumns('Subscriptions'),
|
||||
},
|
||||
{
|
||||
name: 'Crontabs',
|
||||
mode: 'extension',
|
||||
requiredColumns: legacyColumns('Crontabs'),
|
||||
},
|
||||
{
|
||||
name: 'Envs',
|
||||
mode: 'extension',
|
||||
requiredColumns: legacyColumns('Envs'),
|
||||
},
|
||||
{
|
||||
name: RUN_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: unique([
|
||||
...runSchemaManifest.tables.Runs,
|
||||
...Object.keys(runCancellationRequestManifest.columns),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: RUN_ATTEMPT_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: unique([
|
||||
...runSchemaManifest.tables.RunAttempts,
|
||||
...Object.keys(runAttemptDeadlineManifest.columns),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: RUN_EVENT_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: runSchemaManifest.tables.RunEvents,
|
||||
},
|
||||
{
|
||||
name: RUNNING_INSTANCE_TABLE,
|
||||
mode: 'extension',
|
||||
requiredColumns: Object.keys(runningInstanceRunReferenceManifest.columns),
|
||||
},
|
||||
{
|
||||
name: RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: runCancellationDispatchManifest.columns,
|
||||
},
|
||||
{
|
||||
name: COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: completionReceiptJournalManifest.columns,
|
||||
},
|
||||
{
|
||||
name: WORKER_REGISTRY_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: workerRegistryManifest.columns,
|
||||
},
|
||||
{
|
||||
name: RUN_DISPATCH_LEASE_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: runDispatchLeaseManifest.columns,
|
||||
},
|
||||
{
|
||||
name: RUN_RETRY_POLICY_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: runRetryPolicyManifest.columns,
|
||||
},
|
||||
{
|
||||
name: TASK_EXECUTION_REVISION_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: taskExecutionRevisionManifest.columns,
|
||||
},
|
||||
{
|
||||
name: LOCAL_EXECUTION_CONTEXT_RECIPE_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: localExecutionContextRecipeManifest.columns,
|
||||
},
|
||||
{
|
||||
name: LOCAL_SECRET_ENVELOPE_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: localSecretEnvelopeManifest.columns,
|
||||
},
|
||||
{
|
||||
name: LOCAL_ARTIFACT_RETENTION_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: localArtifactRetentionManifest.columns,
|
||||
},
|
||||
{
|
||||
name: LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: localArtifactMaintenanceCursorManifest.columns,
|
||||
},
|
||||
{
|
||||
name: PROJECT_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: projectPolicyManifest.tables.Projects,
|
||||
},
|
||||
{
|
||||
name: PROJECT_ROLE_BINDING_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: projectPolicyManifest.tables.ProjectRoleBindings,
|
||||
},
|
||||
{
|
||||
name: PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: projectOwnerBootstrapManifest.columns,
|
||||
},
|
||||
{
|
||||
name: IDENTITY_SUBJECT_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: identityDirectoryManifest.tables.IdentitySubjects,
|
||||
},
|
||||
{
|
||||
name: IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns:
|
||||
identityDirectoryManifest.tables.IdentityAuthenticationBindings,
|
||||
},
|
||||
{
|
||||
name: APPROVAL_REQUEST_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: approvalRequestManifest.tables.ApprovalRequests,
|
||||
},
|
||||
{
|
||||
name: APPROVED_ACTION_DISPATCH_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: approvalRequestManifest.tables.ApprovedActionDispatches,
|
||||
},
|
||||
{
|
||||
name: APPROVED_ACTION_DISPATCH_EXECUTION_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: approvedActionDispatchExecutionManifest.columns,
|
||||
},
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns:
|
||||
approvedActionRecoveryManifest.tables.ApprovedActionRecoveryControls,
|
||||
},
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns:
|
||||
approvedActionRecoveryManifest.tables.ApprovedActionRecoveryResolutions,
|
||||
},
|
||||
{
|
||||
name: APPROVED_RUN_ACTION_RECEIPT_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: approvedRunActionReceiptManifest.columns,
|
||||
},
|
||||
{
|
||||
name: APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE,
|
||||
mode: 'full',
|
||||
requiredColumns: approvedActionRecoveryAuthorizationManifest.columns,
|
||||
},
|
||||
],
|
||||
indexes: unique([
|
||||
...runSchemaManifest.indexes,
|
||||
...runningInstanceRunReferenceManifest.indexes.map(indexName),
|
||||
...runCancellationRequestManifest.indexes.map(indexName),
|
||||
...runCancellationDispatchManifest.indexes.map(indexName),
|
||||
...runAttemptDeadlineManifest.indexes.map(indexName),
|
||||
...completionReceiptJournalManifest.indexes.map(indexName),
|
||||
...workerRegistryManifest.indexes.map(indexName),
|
||||
...runDispatchLeaseManifest.indexes.map(indexName),
|
||||
...runDispatchCandidateManifest.indexes.map(indexName),
|
||||
...runRetryPolicyManifest.indexes.map(indexName),
|
||||
...taskExecutionRevisionManifest.indexes.map(indexName),
|
||||
...localExecutionContextRecipeManifest.indexes.map(indexName),
|
||||
...localSecretEnvelopeManifest.indexes.map(indexName),
|
||||
...localArtifactRetentionManifest.indexes.map(indexName),
|
||||
...localArtifactMaintenanceCursorManifest.indexes.map(indexName),
|
||||
...projectPolicyManifest.indexes.map(indexName),
|
||||
...projectOwnerBootstrapManifest.indexes.map(indexName),
|
||||
...identityDirectoryManifest.indexes.map(indexName),
|
||||
...approvalRequestManifest.indexes.map(indexName),
|
||||
...approvedActionDispatchExecutionManifest.indexes.map(indexName),
|
||||
...approvedActionRecoveryManifest.indexes.map(indexName),
|
||||
...approvedRunActionReceiptManifest.indexes.map(indexName),
|
||||
...approvedActionRecoveryAuthorizationManifest.indexes.map(indexName),
|
||||
]).map((name) => ({ name })),
|
||||
constraints: unique([
|
||||
...runSchemaManifest.constraints,
|
||||
...runCancellationDispatchManifest.constraints,
|
||||
...completionReceiptJournalManifest.constraints,
|
||||
...workerRegistryManifest.constraints,
|
||||
...runDispatchLeaseManifest.constraints,
|
||||
...runRetryPolicyManifest.constraints,
|
||||
...taskExecutionRevisionManifest.constraints,
|
||||
...localExecutionContextRecipeManifest.constraints,
|
||||
...localSecretEnvelopeManifest.constraints,
|
||||
...localArtifactRetentionManifest.constraints,
|
||||
...localArtifactMaintenanceCursorManifest.constraints,
|
||||
...projectPolicyManifest.constraints,
|
||||
...projectOwnerBootstrapManifest.constraints,
|
||||
...identityDirectoryManifest.constraints,
|
||||
...approvalRequestManifest.constraints,
|
||||
...approvedActionDispatchExecutionManifest.constraints,
|
||||
...approvedActionRecoveryManifest.constraints,
|
||||
...approvedRunActionReceiptManifest.constraints,
|
||||
...approvedActionRecoveryAuthorizationManifest.constraints,
|
||||
]),
|
||||
unknownObjectPolicy: 'preserve-and-report',
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { QueryInterface, Transaction } from 'sequelize';
|
||||
|
||||
export interface MigrationContext {
|
||||
queryInterface: QueryInterface;
|
||||
transaction: Transaction;
|
||||
}
|
||||
|
||||
export interface Migration {
|
||||
id: string;
|
||||
checksum: string;
|
||||
up(context: MigrationContext): Promise<void>;
|
||||
}
|
||||
+16
-1
@@ -152,6 +152,7 @@ enum NotificationMode {
|
||||
ntfy = 19;
|
||||
wxPusherBot = 20;
|
||||
wxPusherSpt = 21;
|
||||
wpush = 22;
|
||||
}
|
||||
|
||||
message NotificationInfo {
|
||||
@@ -247,6 +248,10 @@ message NotificationInfo {
|
||||
optional string wxPusherBotUids = 68;
|
||||
|
||||
optional string wxPusherSptList = 70;
|
||||
|
||||
optional string wpushApiKey = 71;
|
||||
optional string wpushChannel = 72;
|
||||
optional string wpushTopicCode = 73;
|
||||
}
|
||||
|
||||
message SystemNotifyRequest {
|
||||
@@ -255,6 +260,15 @@ message SystemNotifyRequest {
|
||||
optional NotificationInfo notificationInfo = 3;
|
||||
}
|
||||
|
||||
message RecordNotifyLogRequest {
|
||||
string title = 1;
|
||||
string content = 2;
|
||||
bool success = 3;
|
||||
string notifyType = 4;
|
||||
string scriptName = 5;
|
||||
string taskId = 6;
|
||||
}
|
||||
|
||||
service Api {
|
||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||
@@ -266,6 +280,7 @@ service Api {
|
||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
|
||||
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||
@@ -275,4 +290,4 @@ service Api {
|
||||
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
|
||||
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
|
||||
rpc RunCrons(RunCronsRequest) returns (Response) {}
|
||||
}
|
||||
}
|
||||
|
||||
+235
-1
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.21.12
|
||||
// protoc v6.31.1
|
||||
// source: back/protos/api.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -44,6 +44,7 @@ export enum NotificationMode {
|
||||
ntfy = 19,
|
||||
wxPusherBot = 20,
|
||||
wxPusherSpt = 21,
|
||||
wpush = 22,
|
||||
UNRECOGNIZED = -1,
|
||||
}
|
||||
|
||||
@@ -115,6 +116,9 @@ export function notificationModeFromJSON(object: any): NotificationMode {
|
||||
case 21:
|
||||
case "wxPusherSpt":
|
||||
return NotificationMode.wxPusherSpt;
|
||||
case 22:
|
||||
case "wpush":
|
||||
return NotificationMode.wpush;
|
||||
case -1:
|
||||
case "UNRECOGNIZED":
|
||||
default:
|
||||
@@ -168,6 +172,8 @@ export function notificationModeToJSON(object: NotificationMode): string {
|
||||
return "wxPusherBot";
|
||||
case NotificationMode.wxPusherSpt:
|
||||
return "wxPusherSpt";
|
||||
case NotificationMode.wpush:
|
||||
return "wpush";
|
||||
case NotificationMode.UNRECOGNIZED:
|
||||
default:
|
||||
return "UNRECOGNIZED";
|
||||
@@ -400,6 +406,9 @@ export interface NotificationInfo {
|
||||
wxPusherBotTopicIds?: string | undefined;
|
||||
wxPusherBotUids?: string | undefined;
|
||||
wxPusherSptList?: string | undefined;
|
||||
wpushApiKey?: string | undefined;
|
||||
wpushChannel?: string | undefined;
|
||||
wpushTopicCode?: string | undefined;
|
||||
}
|
||||
|
||||
export interface SystemNotifyRequest {
|
||||
@@ -408,6 +417,15 @@ export interface SystemNotifyRequest {
|
||||
notificationInfo?: NotificationInfo | undefined;
|
||||
}
|
||||
|
||||
export interface RecordNotifyLogRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
notifyType: string;
|
||||
scriptName: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function createBaseEnvItem(): EnvItem {
|
||||
return {
|
||||
id: undefined,
|
||||
@@ -2967,6 +2985,9 @@ function createBaseNotificationInfo(): NotificationInfo {
|
||||
wxPusherBotTopicIds: undefined,
|
||||
wxPusherBotUids: undefined,
|
||||
wxPusherSptList: undefined,
|
||||
wpushApiKey: undefined,
|
||||
wpushChannel: undefined,
|
||||
wpushTopicCode: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3182,6 +3203,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
if (message.wxPusherSptList !== undefined) {
|
||||
writer.uint32(562).string(message.wxPusherSptList);
|
||||
}
|
||||
if (message.wpushApiKey !== undefined) {
|
||||
writer.uint32(570).string(message.wpushApiKey);
|
||||
}
|
||||
if (message.wpushChannel !== undefined) {
|
||||
writer.uint32(578).string(message.wpushChannel);
|
||||
}
|
||||
if (message.wpushTopicCode !== undefined) {
|
||||
writer.uint32(586).string(message.wpushTopicCode);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -3752,6 +3782,30 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
message.wxPusherSptList = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 71: {
|
||||
if (tag !== 570) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushApiKey = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 72: {
|
||||
if (tag !== 578) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushChannel = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 73: {
|
||||
if (tag !== 586) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushTopicCode = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -3845,6 +3899,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
: undefined,
|
||||
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined,
|
||||
wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined,
|
||||
wpushApiKey: isSet(object.wpushApiKey) ? globalThis.String(object.wpushApiKey) : undefined,
|
||||
wpushChannel: isSet(object.wpushChannel) ? globalThis.String(object.wpushChannel) : undefined,
|
||||
wpushTopicCode: isSet(object.wpushTopicCode) ? globalThis.String(object.wpushTopicCode) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4060,6 +4117,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
if (message.wxPusherSptList !== undefined) {
|
||||
obj.wxPusherSptList = message.wxPusherSptList;
|
||||
}
|
||||
if (message.wpushApiKey !== undefined) {
|
||||
obj.wpushApiKey = message.wpushApiKey;
|
||||
}
|
||||
if (message.wpushChannel !== undefined) {
|
||||
obj.wpushChannel = message.wpushChannel;
|
||||
}
|
||||
if (message.wpushTopicCode !== undefined) {
|
||||
obj.wpushTopicCode = message.wpushTopicCode;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -4138,6 +4204,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
|
||||
message.wxPusherBotUids = object.wxPusherBotUids ?? undefined;
|
||||
message.wxPusherSptList = object.wxPusherSptList ?? undefined;
|
||||
message.wpushApiKey = object.wpushApiKey ?? undefined;
|
||||
message.wpushChannel = object.wpushChannel ?? undefined;
|
||||
message.wpushTopicCode = object.wpushTopicCode ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
@@ -4236,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
|
||||
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
|
||||
}
|
||||
|
||||
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
|
||||
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.title !== "") {
|
||||
writer.uint32(10).string(message.title);
|
||||
}
|
||||
if (message.content !== "") {
|
||||
writer.uint32(18).string(message.content);
|
||||
}
|
||||
if (message.success !== false) {
|
||||
writer.uint32(24).bool(message.success);
|
||||
}
|
||||
if (message.notifyType !== "") {
|
||||
writer.uint32(34).string(message.notifyType);
|
||||
}
|
||||
if (message.scriptName !== "") {
|
||||
writer.uint32(42).string(message.scriptName);
|
||||
}
|
||||
if (message.taskId !== "") {
|
||||
writer.uint32(50).string(message.taskId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseRecordNotifyLogRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.title = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 24) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.success = reader.bool();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.notifyType = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.scriptName = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.taskId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): RecordNotifyLogRequest {
|
||||
return {
|
||||
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
|
||||
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
|
||||
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
|
||||
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: RecordNotifyLogRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.title !== "") {
|
||||
obj.title = message.title;
|
||||
}
|
||||
if (message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
if (message.success !== false) {
|
||||
obj.success = message.success;
|
||||
}
|
||||
if (message.notifyType !== "") {
|
||||
obj.notifyType = message.notifyType;
|
||||
}
|
||||
if (message.scriptName !== "") {
|
||||
obj.scriptName = message.scriptName;
|
||||
}
|
||||
if (message.taskId !== "") {
|
||||
obj.taskId = message.taskId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
|
||||
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
|
||||
const message = createBaseRecordNotifyLogRequest();
|
||||
message.title = object.title ?? "";
|
||||
message.content = object.content ?? "";
|
||||
message.success = object.success ?? false;
|
||||
message.notifyType = object.notifyType ?? "";
|
||||
message.scriptName = object.scriptName ?? "";
|
||||
message.taskId = object.taskId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type ApiService = typeof ApiService;
|
||||
export const ApiService = {
|
||||
getEnvs: {
|
||||
@@ -4328,6 +4537,15 @@ export const ApiService = {
|
||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||
},
|
||||
recordNotifyLog: {
|
||||
path: "/com.ql.api.Api/RecordNotifyLog",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
|
||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||
},
|
||||
getCronDetail: {
|
||||
path: "/com.ql.api.Api/GetCronDetail",
|
||||
requestStream: false,
|
||||
@@ -4422,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
|
||||
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
||||
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
||||
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
||||
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
|
||||
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
||||
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
||||
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
||||
@@ -4584,6 +4803,21 @@ export interface ApiClient extends Client {
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
getCronDetail(
|
||||
request: CronDetailRequest,
|
||||
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user