mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
317
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0118be41f9 | ||
|
|
efb7379fa9 | ||
|
|
ed4ba9b817 | ||
|
|
eaf1370cd5 | ||
|
|
e684a6e64a | ||
|
|
e4ba5d405f | ||
|
|
2005cb6ba9 | ||
|
|
03afd7e899 | ||
|
|
9708a37154 | ||
|
|
ce8c3a7d2a | ||
|
|
8e1f1ed7ae | ||
|
|
12ed38b7d7 | ||
|
|
7016bb91b0 | ||
|
|
09ef174522 | ||
|
|
fa730da091 | ||
|
|
a1b21443ba | ||
|
|
1b223ff2ad | ||
|
|
ca41640794 | ||
|
|
882ce07d7e | ||
|
|
83966a1287 | ||
|
|
cbdddffc91 | ||
|
|
a8ca739fe8 | ||
|
|
f76e5f16da | ||
|
|
53672c468b | ||
|
|
3df73261ab | ||
|
|
15d970be41 | ||
|
|
d6595912a2 | ||
|
|
f8ec9fccec | ||
|
|
1cd298ebaf | ||
|
|
6717a3dfa6 | ||
|
|
950f638998 | ||
|
|
a95ed67fe8 | ||
|
|
37801d2f30 | ||
|
|
c8d9eed95d | ||
|
|
8580b25ecc | ||
|
|
7ed7a08211 | ||
|
|
0235973c9b | ||
|
|
367ec49e4a | ||
|
|
45879ef0a7 | ||
|
|
229c3cb4e8 | ||
|
|
b3421ac9cf | ||
|
|
50e6742b72 | ||
|
|
79045a0d43 | ||
|
|
a76618e176 | ||
|
|
2215a29afb | ||
|
|
fe8dfc3379 | ||
|
|
652653c7f6 | ||
|
|
c0a4901186 | ||
|
|
b172ff5e02 | ||
|
|
4b0241f992 | ||
|
|
32c0143f5d | ||
|
|
a03c94dc05 | ||
|
|
1b6523e93c | ||
|
|
6edf6ff3c5 | ||
|
|
93b45ca62d | ||
|
|
784e77a971 | ||
|
|
419e1bc141 | ||
|
|
0f4b6b43fb | ||
|
|
38d85952e7 | ||
|
|
7a8acacb6c | ||
|
|
157b1d9afd | ||
|
|
d6571e4b89 | ||
|
|
6e5bfccdfc | ||
|
|
97333da34c | ||
|
|
be7a3fea41 | ||
|
|
d459c3b45c | ||
|
|
f46fb44ac9 | ||
|
|
53d65abd8d | ||
|
|
b970e2aede | ||
|
|
951de26ffd | ||
|
|
f28bf74d1b | ||
|
|
6239c4d698 | ||
|
|
9089c5a8e6 | ||
|
|
dc1686bd6f | ||
|
|
884912d1c8 | ||
|
|
53b7072370 | ||
|
|
37abfa160d | ||
|
|
b80dc56398 | ||
|
|
57953ec80b | ||
|
|
920a31fb66 | ||
|
|
ce4284ff76 | ||
|
|
6342e11a10 | ||
|
|
c60ab7e48a | ||
|
|
84d1a61372 | ||
|
|
b7a8104d9f | ||
|
|
82d74cf56f | ||
|
|
07d1dd97c1 | ||
|
|
a7308c78f9 | ||
|
|
78c261b556 | ||
|
|
238df17fdf | ||
|
|
4239464af6 | ||
|
|
218521994a | ||
|
|
2620be0587 | ||
|
|
2253b99066 | ||
|
|
3731ae6051 | ||
|
|
e3c05862b8 | ||
|
|
b48be7f058 | ||
|
|
f8934b401d | ||
|
|
eb34662d01 | ||
|
|
beb490c48c | ||
|
|
9c58b15d2b | ||
|
|
ac0992081e | ||
|
|
b45a5e04b7 | ||
|
|
2c107d1e9d | ||
|
|
07cdc76bae | ||
|
|
c2df0c7215 | ||
|
|
d2ebce85fa | ||
|
|
309694ebde | ||
|
|
a027c92f26 | ||
|
|
9afcf9413c | ||
|
|
82348e9f91 | ||
|
|
52ebc850c8 | ||
|
|
02a5a4712d | ||
|
|
c6b9e1504c | ||
|
|
2eb8b29e51 | ||
|
|
931f9eee38 | ||
|
|
fbedae0116 | ||
|
|
7b3d49acda | ||
|
|
9c736f0943 | ||
|
|
b0ab255a68 | ||
|
|
686873f8ec | ||
|
|
a64d08bda1 | ||
|
|
4cf806b4b2 | ||
|
|
bd8678a888 | ||
|
|
4abf125ce9 | ||
|
|
cf2c0ec7b3 | ||
|
|
784d9b21a0 | ||
|
|
54056e8bab | ||
|
|
b731b434f7 | ||
|
|
fd10f72aba | ||
|
|
d94ca02e0d | ||
|
|
8ad8ed96bd | ||
|
|
7142395897 | ||
|
|
f822e92b02 | ||
|
|
e261d4130d | ||
|
|
45f8218247 | ||
|
|
31a9bcbe83 | ||
|
|
74ae891070 | ||
|
|
5dd8678d7e | ||
|
|
fbb67ff5c8 | ||
|
|
dbd219bedc | ||
|
|
f2b198ff05 | ||
|
|
6001174e23 | ||
|
|
85e244f602 | ||
|
|
42262094cc | ||
|
|
5f62f2f724 | ||
|
|
4d71dfb937 | ||
|
|
ab589e9ecb | ||
|
|
4541d142d3 | ||
|
|
546b02a0bc | ||
|
|
2af3ccc158 | ||
|
|
e6089fb77c | ||
|
|
a82b7fb98b | ||
|
|
2638df29c8 | ||
|
|
e6615c7088 | ||
|
|
35efa1f33e | ||
|
|
1250555881 | ||
|
|
9bf3472c14 | ||
|
|
d55933d781 | ||
|
|
a1549b3a24 | ||
|
|
3e0fc8b46f | ||
|
|
42c1bfbb56 | ||
|
|
eaf9bf486a | ||
|
|
47118e13fe | ||
|
|
3fb02f4377 | ||
|
|
c0cd94f50d | ||
|
|
5efaca3d5a | ||
|
|
66668ffdcd | ||
|
|
c8ad5ea368 | ||
|
|
f1cca9a3ed | ||
|
|
ca8ddf34b8 | ||
|
|
1a4acaa56f | ||
|
|
1834c00ff7 | ||
|
|
c218f71250 | ||
|
|
22f58b1eff | ||
|
|
827ba5294f | ||
|
|
473a535815 | ||
|
|
46a7dc7496 | ||
|
|
4012f54a50 | ||
|
|
f527d83c42 | ||
|
|
c341a92a18 | ||
|
|
a789c4a4d6 | ||
|
|
1bf14f807c | ||
|
|
b714f2bf18 | ||
|
|
4f6be5bc2c | ||
|
|
190117a662 | ||
|
|
07cb5ea0a8 | ||
|
|
f1fc316be1 | ||
|
|
3a725cc8e8 | ||
|
|
744a948de1 | ||
|
|
d4813e48a4 | ||
|
|
69c322fa8d | ||
|
|
19bb09faa3 | ||
|
|
878a360b09 | ||
|
|
c9e41812cb | ||
|
|
4ea156f189 | ||
|
|
3c02ae2020 | ||
|
|
2cd6ea1d55 | ||
|
|
ad291e514d | ||
|
|
0c503932cc | ||
|
|
c0ef9a969e | ||
|
|
ffa4b4e7cb | ||
|
|
344680d64a | ||
|
|
af5d5bfc0b | ||
|
|
4a4fa85f51 | ||
|
|
6265e31dce | ||
|
|
6e11258d45 | ||
|
|
3979707f6d | ||
|
|
0a5f1448f1 | ||
|
|
cf21e984cb | ||
|
|
095683a4cb | ||
|
|
5a979e510e | ||
|
|
5261c41828 | ||
|
|
0b5f3bcb39 | ||
|
|
1809fbb8d3 | ||
|
|
36035ac43e | ||
|
|
9981f47851 | ||
|
|
be98d6753b | ||
|
|
c60a86d1bf | ||
|
|
b739d92347 | ||
|
|
d38f624262 | ||
|
|
6cb9a075d7 | ||
|
|
1ff5aed844 | ||
|
|
0ad96d38a6 | ||
|
|
6831ea3de5 | ||
|
|
ca19423f1d | ||
|
|
1c4e3269b4 | ||
|
|
a99f1508f7 | ||
|
|
dbd57b27d0 | ||
|
|
7ac5e678ba | ||
|
|
cc6d6b7b48 | ||
|
|
3e7bfa8087 | ||
|
|
fd433c41cd | ||
|
|
9cd42ff454 | ||
|
|
8496b56bed | ||
|
|
2e848ef7c0 | ||
|
|
abf2d25d63 | ||
|
|
dce72c800f | ||
|
|
2ccdd71851 | ||
|
|
00a65360cf | ||
|
|
8c09850249 | ||
|
|
a44c213be1 | ||
|
|
41549524d4 | ||
|
|
bad8399cc3 | ||
|
|
7130d77a76 | ||
|
|
d991deb7ee | ||
|
|
33b9eb2f5e | ||
|
|
4f62a86d15 | ||
|
|
7955d55629 | ||
|
|
fba8dfb602 | ||
|
|
c4a1238a92 | ||
|
|
da4e7cf688 | ||
|
|
58025ede55 | ||
|
|
0e85cbdeb6 | ||
|
|
e5a3d83a8d | ||
|
|
f77b19e615 | ||
|
|
774af4a4c9 | ||
|
|
0a31c3364b | ||
|
|
5fc70010f2 | ||
|
|
c3bd6d40bb | ||
|
|
453a7679b2 | ||
|
|
9c90c6ce82 | ||
|
|
0163c94be7 | ||
|
|
aae96390cc | ||
|
|
93690dd921 | ||
|
|
82adb9ace2 | ||
|
|
fef0fe2bd1 | ||
|
|
828370c60b | ||
|
|
a55613afaa | ||
|
|
3b0aa97303 | ||
|
|
1d83d1d6bb | ||
|
|
51145a8028 | ||
|
|
1c75ac0f45 | ||
|
|
2485d61e8a | ||
|
|
056eebef94 | ||
|
|
3ca9901055 | ||
|
|
d8489d8a0b | ||
|
|
7f5339e047 | ||
|
|
75dbe7a4c4 | ||
|
|
53c150c4ec | ||
|
|
9d7431c2cd | ||
|
|
d41c821ce0 | ||
|
|
5f93e0b5d2 | ||
|
|
c877147d0f | ||
|
|
326783ff14 | ||
|
|
f111d8b10c | ||
|
|
d8c4503adf | ||
|
|
22eea3ac71 | ||
|
|
73eeaed4de | ||
|
|
7016903fba | ||
|
|
56d06bd6cc | ||
|
|
3a58e1508e | ||
|
|
e43fd607aa | ||
|
|
5e70b89f55 | ||
|
|
8ba27dc148 | ||
|
|
d72631a2b0 | ||
|
|
55ef085a6e | ||
|
|
4c2a0b6adf | ||
|
|
a6ad636251 | ||
|
|
a83b4c5e8d | ||
|
|
ea3bbe35c3 | ||
|
|
2934123155 | ||
|
|
bd0621c9f2 | ||
|
|
d85cd88c2d | ||
|
|
5c89e3bc60 | ||
|
|
86eb0f1eb3 | ||
|
|
dd370b2842 | ||
|
|
c0ab62e64a | ||
|
|
e38b143dbb | ||
|
|
6b7f8913b5 | ||
|
|
42e8992818 | ||
|
|
5d3b40ce3b | ||
|
|
40628c843d | ||
|
|
2bfa8ca279 | ||
|
|
308aa75d89 | ||
|
|
c699c32461 | ||
|
|
4bf92dcfeb |
@@ -6,13 +6,6 @@ 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=
|
||||
|
||||
@@ -216,8 +216,6 @@ 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
|
||||
@@ -297,8 +295,6 @@ 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
|
||||
@@ -363,8 +359,6 @@ 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
|
||||
@@ -431,8 +425,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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
@@ -0,0 +1,134 @@
|
||||
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
@@ -0,0 +1,90 @@
|
||||
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
|
||||
@@ -0,0 +1,124 @@
|
||||
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
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
+3
-3
@@ -2,6 +2,8 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/packages/*/node_modules
|
||||
/packages/*/dist
|
||||
/npm-debug.log*
|
||||
/yarn-error.log
|
||||
/yarn.lock
|
||||
@@ -9,6 +11,7 @@
|
||||
|
||||
# production
|
||||
/static
|
||||
/.ql3-panel-dist
|
||||
/data
|
||||
|
||||
# misc
|
||||
@@ -35,6 +38,3 @@ __pycache__
|
||||
|
||||
# local Kubernetes overlays
|
||||
/deploy/kubernetes/overlays/local/
|
||||
|
||||
# Local security audit artifacts
|
||||
/audit/
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
strict-peer-dependencies=false
|
||||
strict-peer-dependencies=false
|
||||
resolve-peers-from-workspace-root=false
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
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 : '配置更新失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/diagnose',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({ code: 200, data: await diagnoseClientIp(req) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+19
-13
@@ -9,7 +9,6 @@ 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) => {
|
||||
@@ -79,12 +78,14 @@ export default (app: Router) => {
|
||||
basePath = join(config.rootPath, 'data/scripts');
|
||||
}
|
||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||
const normalized = resolveFileAccess(
|
||||
basePath,
|
||||
[cleanName],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!normalized) {
|
||||
const resolvedPath = join(basePath, cleanName);
|
||||
const normalized = join(resolvedPath);
|
||||
// Verify the resolved path stays within allowed directory
|
||||
if (!normalized.startsWith(basePath)) {
|
||||
return res.send({ code: 403, message: t('文件路径无效') });
|
||||
}
|
||||
// Check blacklist on actual filename (not user input)
|
||||
if (config.blackFileList.includes(basename(normalized))) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(normalized, content);
|
||||
@@ -95,10 +96,15 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
+12
-24
@@ -5,10 +5,7 @@ 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';
|
||||
|
||||
const route = Router();
|
||||
@@ -307,34 +304,16 @@ 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, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
const result = await cronService.log(req.params.id);
|
||||
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);
|
||||
@@ -457,6 +436,11 @@ 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) => {
|
||||
@@ -505,7 +489,11 @@ 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,43 +107,6 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/failures',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']],
|
||||
where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col('fail_count')), '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 || '',
|
||||
failCount: Number(row.fail_count),
|
||||
deleted: !cron,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/trend',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -12,8 +12,6 @@ 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();
|
||||
@@ -30,8 +28,6 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
+21
-30
@@ -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,20 +34,6 @@ 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);
|
||||
@@ -66,19 +52,11 @@ export default (app: Router) => {
|
||||
where: { log_path: logPath, status: InstanceStatus.running },
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({
|
||||
code: 200,
|
||||
data: removeAnsi(chunk.content),
|
||||
data: removeAnsi(content),
|
||||
logStatus: runningInstance ? 'running' : undefined,
|
||||
offset: chunk.offset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
total: chunk.total,
|
||||
truncated: chunk.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -88,11 +66,24 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
(req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||
});
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+43
-34
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||
import {
|
||||
fileExist,
|
||||
isPathInside,
|
||||
readDirs,
|
||||
readDir,
|
||||
rmPath,
|
||||
IFile,
|
||||
} from '../config/util';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
@@ -16,17 +21,17 @@ const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((x) =>
|
||||
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
|
||||
return config.writePathList.some((rootPath) =>
|
||||
isPathInside(rootPath, resolved),
|
||||
);
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.tmpPath);
|
||||
cb(null, config.scriptPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, randomUUID());
|
||||
cb(null, file.originalname);
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
@@ -54,15 +59,6 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(
|
||||
config.scriptPath,
|
||||
[req.query.path as string],
|
||||
config.blackFileList,
|
||||
)
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
@@ -90,8 +86,7 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
@@ -105,7 +100,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 });
|
||||
@@ -115,21 +110,32 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||
});
|
||||
});
|
||||
route.get(
|
||||
'/:file',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query?.path as string || '',
|
||||
req.params.file,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
(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({
|
||||
@@ -161,7 +167,11 @@ export default (app: Router) => {
|
||||
if (!path.startsWith('/')) {
|
||||
path = join(config.scriptPath, path);
|
||||
}
|
||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||
if (
|
||||
config.writePathList.every(
|
||||
(rootPath) => !isPathInside(rootPath, path),
|
||||
)
|
||||
) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
@@ -173,8 +183,7 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(uploadPath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.copyFile(req.file.path, uploadPath);
|
||||
await fs.unlink(req.file.path);
|
||||
await fs.rename(req.file.path, uploadPath);
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
|
||||
|
||||
@@ -148,27 +148,13 @@ 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 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 });
|
||||
const data = await subscriptionService.log(req.params.id);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
@@ -350,11 +350,6 @@ 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(),
|
||||
},
|
||||
}),
|
||||
@@ -366,7 +361,6 @@ export default (app: Router) => {
|
||||
req.query as {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
+13
-86
@@ -22,25 +22,7 @@ const storage = multer.diskStorage({
|
||||
cb(null, key + ext);
|
||||
},
|
||||
});
|
||||
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);
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/user', route);
|
||||
@@ -53,8 +35,8 @@ export default (app: Router) => {
|
||||
}),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -88,8 +70,8 @@ export default (app: Router) => {
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -98,8 +80,8 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -174,12 +156,11 @@ 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().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -208,59 +189,6 @@ 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) => {
|
||||
@@ -291,19 +219,18 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/init',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const result = await userService.initializeUser(req.body);
|
||||
res.send(result);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+55
-3
@@ -23,6 +23,12 @@ 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;
|
||||
@@ -242,10 +248,33 @@ class Application {
|
||||
const appLoader = await import('./loaders/app');
|
||||
await appLoader.default({ app: this.app });
|
||||
|
||||
const server = await this.httpServerService.initialize(
|
||||
this.app,
|
||||
config.port,
|
||||
const { bootstrapLegacyShadowStartupReconciliation } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
|
||||
);
|
||||
const legacyShadowStartup =
|
||||
await bootstrapLegacyShadowStartupReconciliation();
|
||||
|
||||
const { bootstrapLegacyShadowCaptureEvidence } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence'
|
||||
);
|
||||
this.legacyShadowCaptureEvidence =
|
||||
await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: legacyShadowStartup,
|
||||
});
|
||||
|
||||
const { bootstrapDefaultManualPrimaryRuntime } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime'
|
||||
);
|
||||
this.manualPrimaryRuntime = await bootstrapDefaultManualPrimaryRuntime();
|
||||
|
||||
let server;
|
||||
try {
|
||||
server = await this.httpServerService.initialize(this.app, config.port);
|
||||
} catch (error) {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const serverLoader = await import('./loaders/server');
|
||||
await (serverLoader.default as any)({ server });
|
||||
@@ -289,7 +318,9 @@ class Application {
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.httpServerService?.shutdown();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
} else {
|
||||
await this.grpcServerService?.shutdown();
|
||||
}
|
||||
@@ -299,6 +330,27 @@ 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();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 = [
|
||||
@@ -67,20 +65,20 @@ export function createRandomString(min: number, max: number): string {
|
||||
arr.push(getOne(ENGLISH));
|
||||
arr.push(getOne(special));
|
||||
|
||||
const len = min + randomInt(max - min + 1);
|
||||
const len = min + Math.floor(Math.random() * (max - min + 1));
|
||||
|
||||
for (let i = 4; i < len; i++) {
|
||||
arr.push(config[randomInt(config.length)]);
|
||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
||||
}
|
||||
|
||||
const newArr = [];
|
||||
for (let j = 0; j < len; j++) {
|
||||
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
|
||||
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
|
||||
}
|
||||
|
||||
function getOne(arr: any[]) {
|
||||
return arr[randomInt(arr.length)];
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
return newArr.join('');
|
||||
}
|
||||
}
|
||||
+64
-95
@@ -214,105 +214,48 @@ 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[]> {
|
||||
return readDirsWithRunner(
|
||||
dir,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
|
||||
);
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const files = await fs.readdir(dir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const subPath = path.join(dir, file);
|
||||
const stats = await fs.lstat(subPath);
|
||||
const key = path.join(relativePath, file);
|
||||
|
||||
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const children = await readDirs(subPath, baseDir, blacklist, sort);
|
||||
result.push({
|
||||
title: file,
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children: children.sort(sort),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
title: file,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort(sort);
|
||||
}
|
||||
|
||||
export async function readDir(
|
||||
@@ -320,13 +263,26 @@ export async function readDir(
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
): Promise<IFile[]> {
|
||||
const absoluteBaseDir = path.resolve(baseDir);
|
||||
const absoluteDir = path.resolve(baseDir, dir);
|
||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
|
||||
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[] = [];
|
||||
|
||||
@@ -369,6 +325,19 @@ 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, {
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
@@ -22,6 +24,8 @@ 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;
|
||||
@@ -32,6 +36,8 @@ 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;
|
||||
@@ -52,6 +58,15 @@ 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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
@@ -41,8 +41,6 @@ export interface SystemConfigInfo {
|
||||
linuxMirror?: string;
|
||||
timezone?: string;
|
||||
globalSshKey?: string;
|
||||
runningInstanceRetentionDays?: number;
|
||||
cronStatRetentionDays?: number;
|
||||
}
|
||||
|
||||
export interface LoginLogInfo {
|
||||
@@ -75,14 +73,11 @@ 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 &
|
||||
|
||||
@@ -6,7 +6,6 @@ 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();
|
||||
@@ -25,6 +24,5 @@ export default async ({ app }: { app: Application }) => {
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app });
|
||||
await initializeTrustProxy(app);
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+3
-37
@@ -8,7 +8,7 @@ import { SubscriptionModel } from '../data/subscription';
|
||||
import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
import { runMigrations } from '../migrations/runner';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
@@ -21,45 +21,11 @@ export default async () => {
|
||||
await CrontabViewModel.sync();
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
// 初始化新增字段
|
||||
const migrations = [
|
||||
{
|
||||
table: 'CrontabViews',
|
||||
column: 'filterRelation',
|
||||
type: 'VARCHAR(255)',
|
||||
},
|
||||
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
|
||||
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
|
||||
{ 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: 'VARCHAR(255)' },
|
||||
{
|
||||
table: 'Crontabs',
|
||||
column: 'allow_multiple_instances',
|
||||
type: 'NUMBER',
|
||||
},
|
||||
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
|
||||
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||
{ table: 'Envs', column: 'labels', type: 'JSON' },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
await sequelize.query(
|
||||
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Column already exists or other error, continue
|
||||
}
|
||||
}
|
||||
await runMigrations();
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+25
-46
@@ -14,32 +14,30 @@ import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { AppScope } from '../data/open';
|
||||
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', resolveTrustProxy());
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.use(cors());
|
||||
|
||||
// Security: Reject case variations under protected API namespaces before
|
||||
// authentication checks can interpret the request differently from routing.
|
||||
app.use(protectedPathCase);
|
||||
// Security: Path normalization middleware to prevent case variation attacks
|
||||
app.use((req, res, next) => {
|
||||
const originalPath = req.path;
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
// Block requests with case variations on protected paths
|
||||
if (originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Rewrite URLs to strip baseUrl prefix if configured
|
||||
// This allows the rest of the app to work without baseUrl awareness
|
||||
@@ -48,26 +46,7 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||
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(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
||||
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
@@ -118,10 +97,7 @@ 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 已失效'),
|
||||
});
|
||||
@@ -142,12 +118,14 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||
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);
|
||||
});
|
||||
@@ -164,7 +142,8 @@ 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);
|
||||
|
||||
|
||||
+2
-20
@@ -8,44 +8,26 @@ 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);
|
||||
|
||||
echo.on('connection', async (conn) => {
|
||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||
conn.close('404');
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
if (isValidToken(authInfo, headerToken, platform)) {
|
||||
sockService.addClient(conn);
|
||||
const checkSession = setInterval(async () => {
|
||||
try {
|
||||
const current = await shareStore.getAuthInfo();
|
||||
if (
|
||||
!isValidToken(current, headerToken, platform, config.jwt.secret)
|
||||
) {
|
||||
conn.close('401');
|
||||
}
|
||||
} catch {
|
||||
conn.close('401');
|
||||
}
|
||||
}, 1000);
|
||||
checkSession.unref();
|
||||
|
||||
conn.on('data', (message) => {
|
||||
conn.write(message);
|
||||
});
|
||||
|
||||
conn.on('close', function () {
|
||||
clearInterval(checkSession);
|
||||
sockService.removeClient(conn);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,34 +3,46 @@ import Logger from '../loaders/logger';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { metricsService } from '../services/metrics';
|
||||
|
||||
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
|
||||
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
|
||||
let requestSampleOffset = 0;
|
||||
interface RequestMetrics {
|
||||
method: string;
|
||||
path: string;
|
||||
duration: number;
|
||||
statusCode: number;
|
||||
timestamp: number;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
const requestMetrics: RequestMetrics[] = [];
|
||||
|
||||
export const monitoringMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
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 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 }),
|
||||
});
|
||||
const metric: RequestMetrics = {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
duration,
|
||||
statusCode: res.statusCode,
|
||||
timestamp: Date.now(),
|
||||
platform: req.platform,
|
||||
};
|
||||
|
||||
requestMetrics.push(metric);
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
|
||||
if (requestMetrics.length > 1000) {
|
||||
requestMetrics.shift();
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
@@ -46,3 +58,23 @@ 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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,84 @@
|
||||
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] = {};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,436 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,447 @@
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
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(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
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',
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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]',
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
import type {
|
||||
LegacyPanelAuthSnapshot,
|
||||
LegacyPanelAuthSnapshotReader,
|
||||
LegacyPanelPlatform,
|
||||
LegacyPanelSessionSource,
|
||||
} from '../../ports/legacyPanelSessionSource';
|
||||
|
||||
export const MAX_LEGACY_PANEL_TOKEN_LENGTH = 4096;
|
||||
export const MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM = 64;
|
||||
|
||||
export class LegacyPanelSessionUnavailableError extends Error {
|
||||
readonly code = 'LEGACY_PANEL_SESSION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Legacy panel session state is unavailable');
|
||||
this.name = 'LegacyPanelSessionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertToken(value: string): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_LEGACY_PANEL_TOKEN_LENGTH
|
||||
) {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenMatches(left: string, right: string): boolean {
|
||||
assertToken(left);
|
||||
assertToken(right);
|
||||
return timingSafeEqual(
|
||||
createHash('sha256').update(left, 'utf8').digest(),
|
||||
createHash('sha256').update(right, 'utf8').digest(),
|
||||
);
|
||||
}
|
||||
|
||||
function candidates(
|
||||
snapshot: Readonly<LegacyPanelAuthSnapshot>,
|
||||
platform: LegacyPanelPlatform,
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
if (snapshot.token !== undefined && snapshot.token !== '') {
|
||||
assertToken(snapshot.token);
|
||||
result.push(snapshot.token);
|
||||
}
|
||||
if (snapshot.tokens === undefined) return result;
|
||||
if (
|
||||
!snapshot.tokens ||
|
||||
typeof snapshot.tokens !== 'object' ||
|
||||
Array.isArray(snapshot.tokens)
|
||||
) {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
const platformTokens = snapshot.tokens[platform];
|
||||
if (platformTokens === null || platformTokens === undefined) return result;
|
||||
if (typeof platformTokens === 'string') {
|
||||
if (platformTokens === '') return result;
|
||||
assertToken(platformTokens);
|
||||
result.push(platformTokens);
|
||||
return result;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(platformTokens) ||
|
||||
platformTokens.length > MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM
|
||||
) {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
for (const item of platformTokens) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
assertToken(item.value);
|
||||
result.push(item.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class LegacyAuthInfoSessionSource implements LegacyPanelSessionSource {
|
||||
constructor(private readonly read: LegacyPanelAuthSnapshotReader) {
|
||||
if (typeof read !== 'function') {
|
||||
throw new TypeError('Legacy panel auth snapshot reader is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async isActive(
|
||||
token: string,
|
||||
platform: LegacyPanelPlatform,
|
||||
): Promise<boolean> {
|
||||
assertToken(token);
|
||||
if (platform !== 'desktop' && platform !== 'mobile') {
|
||||
throw new TypeError('Legacy panel platform is invalid');
|
||||
}
|
||||
let snapshot: Readonly<LegacyPanelAuthSnapshot> | null;
|
||||
try {
|
||||
snapshot = await this.read();
|
||||
} catch {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
if (!snapshot) return false;
|
||||
if (typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
throw new LegacyPanelSessionUnavailableError();
|
||||
}
|
||||
return candidates(snapshot, platform).some((candidate) =>
|
||||
tokenMatches(candidate, token),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { TextDecoder } from 'util';
|
||||
import {
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
LocalSecretUnavailableError,
|
||||
localSecretBinary,
|
||||
localSecretEnvelopeAad,
|
||||
normalizeLocalSecretEnvelope,
|
||||
type LocalSecretEnvelope,
|
||||
} from '../../domain/localSecret';
|
||||
|
||||
const SECRET_KEY_BYTES = 32;
|
||||
const SECRET_NONCE_BYTES = 12;
|
||||
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export type LocalSecretNonceFactory = () => Uint8Array;
|
||||
|
||||
function ownedSecretKey(key: Uint8Array): Buffer {
|
||||
if (!(key instanceof Uint8Array) || key.byteLength !== SECRET_KEY_BYTES) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return Buffer.from(key);
|
||||
}
|
||||
|
||||
export function encryptLocalSecretEnvelope(
|
||||
metadata: Omit<LocalSecretEnvelope, 'nonce' | 'ciphertext' | 'authTag'>,
|
||||
plaintext: string,
|
||||
key: Uint8Array,
|
||||
nonceFactory: LocalSecretNonceFactory = () => randomBytes(SECRET_NONCE_BYTES),
|
||||
): LocalSecretEnvelope {
|
||||
const ownedKey = ownedSecretKey(key);
|
||||
const plaintextBuffer = Buffer.from(plaintext, 'utf8');
|
||||
let nonce: Buffer | undefined;
|
||||
try {
|
||||
nonce = Buffer.from(nonceFactory());
|
||||
if (nonce.length !== SECRET_NONCE_BYTES) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const cipher = createCipheriv(LOCAL_SECRET_ALGORITHM, ownedKey, nonce, {
|
||||
authTagLength: 16,
|
||||
});
|
||||
cipher.setAAD(localSecretEnvelopeAad(metadata));
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintextBuffer),
|
||||
cipher.final(),
|
||||
]);
|
||||
return normalizeLocalSecretEnvelope({
|
||||
...metadata,
|
||||
nonce: nonce.toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
authTag: cipher.getAuthTag().toString('base64url'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretUnavailableError) throw error;
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
ownedKey.fill(0);
|
||||
plaintextBuffer.fill(0);
|
||||
nonce?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptLocalSecretEnvelopeToBuffer(
|
||||
envelope: LocalSecretEnvelope,
|
||||
key: Uint8Array,
|
||||
): Buffer {
|
||||
const normalized = normalizeLocalSecretEnvelope(envelope);
|
||||
const ownedKey = ownedSecretKey(key);
|
||||
const nonce = localSecretBinary('nonce', normalized.nonce);
|
||||
const ciphertext = localSecretBinary('ciphertext', normalized.ciphertext);
|
||||
const authTag = localSecretBinary('authTag', normalized.authTag);
|
||||
try {
|
||||
const decipher = createDecipheriv(LOCAL_SECRET_ALGORITHM, ownedKey, nonce, {
|
||||
authTagLength: 16,
|
||||
});
|
||||
decipher.setAAD(localSecretEnvelopeAad(normalized));
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
ownedKey.fill(0);
|
||||
nonce.fill(0);
|
||||
authTag.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLocalSecretPlaintext(plaintext: Buffer): string {
|
||||
try {
|
||||
return UTF8_DECODER.decode(plaintext);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CompletionReceipt } from '../../domain/completionReceipt';
|
||||
import { matchesWorkerExecutionCompletionReceiptAuthentication } from '../../domain/workerExecutionCompletionReceiptAuthentication';
|
||||
import type { WorkerExecutionOfferJournalRecord } from '../../domain/workerExecutionOffer';
|
||||
import type { WorkerExecutionCompletionReceiptAuthenticator } from '../../ports/workerExecutionCompletionReceiptAuthenticator';
|
||||
|
||||
export class Sha256WorkerExecutionCompletionReceiptAuthenticator
|
||||
implements WorkerExecutionCompletionReceiptAuthenticator
|
||||
{
|
||||
authenticate(
|
||||
receipt: CompletionReceipt,
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): boolean {
|
||||
if (
|
||||
record.completionReceiptCallbackSequence === undefined ||
|
||||
record.completionReceiptTokenDigest === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return matchesWorkerExecutionCompletionReceiptAuthentication(receipt, {
|
||||
callbackSequence: record.completionReceiptCallbackSequence,
|
||||
tokenDigest: record.completionReceiptTokenDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
assertCompletionReceiptId,
|
||||
InvalidCompletionReceiptError,
|
||||
MAX_COMPLETION_RECEIPT_BYTES,
|
||||
parseCompletionReceipt,
|
||||
serializeCompletionReceipt,
|
||||
type CompletionReceipt,
|
||||
} from '../../domain/completionReceipt';
|
||||
import type { CompletionReceiptStore } from '../../ports/completionReceiptStore';
|
||||
|
||||
export class CompletionReceiptAlreadyExistsError extends Error {
|
||||
constructor(readonly attemptId: string) {
|
||||
super(`Completion receipt already exists for Attempt ${attemptId}`);
|
||||
this.name = 'CompletionReceiptAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded local journal. It does not scan directories or own a lifecycle;
|
||||
* callers must discover active Attempts from the database.
|
||||
*/
|
||||
export class CompletionReceiptFileStore implements CompletionReceiptStore {
|
||||
constructor(private readonly root: string) {
|
||||
if (!path.isAbsolute(root)) {
|
||||
throw new RangeError('Completion receipt root must be absolute');
|
||||
}
|
||||
}
|
||||
|
||||
async publish(receipt: CompletionReceipt): Promise<void> {
|
||||
const serialized = serializeCompletionReceipt(receipt);
|
||||
const directory = this.directory(receipt.attemptId);
|
||||
const target = this.target(receipt.attemptId);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const temporary = path.join(
|
||||
directory,
|
||||
`.${receipt.attemptId}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(temporary, 'wx', 0o600);
|
||||
await handle.writeFile(serialized, 'utf8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
|
||||
// A hard link publishes the fully written inode atomically and, unlike
|
||||
// plain rename(), never replaces an existing completion fact.
|
||||
try {
|
||||
await fs.link(temporary, target);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
throw new CompletionReceiptAlreadyExistsError(receipt.attemptId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.bestEffortUnlink(temporary);
|
||||
await this.bestEffortSyncDirectory(directory);
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
await this.bestEffortUnlink(temporary);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async read(attemptId: string): Promise<CompletionReceipt | undefined> {
|
||||
const target = this.target(attemptId);
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt must not be a symbolic link',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt must be a regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(MAX_COMPLETION_RECEIPT_BYTES + 1);
|
||||
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
||||
if (bytesRead > MAX_COMPLETION_RECEIPT_BYTES) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
const receipt = parseCompletionReceipt(bytes.subarray(0, bytesRead));
|
||||
if (receipt.attemptId !== attemptId) {
|
||||
throw new InvalidCompletionReceiptError(
|
||||
'Completion receipt path and Attempt do not match',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async remove(attemptId: string): Promise<boolean> {
|
||||
const target = this.target(attemptId);
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async quarantine(attemptId: string): Promise<string | undefined> {
|
||||
const target = this.target(attemptId);
|
||||
const reference = this.quarantineReference(attemptId);
|
||||
const relativeDirectory = path.dirname(reference);
|
||||
const directory = path.join(this.root, relativeDirectory);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(directory, 0o700);
|
||||
const quarantined = path.join(this.root, reference);
|
||||
try {
|
||||
await fs.link(target, quarantined);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) {
|
||||
return (await this.pathExists(quarantined)) ? reference : undefined;
|
||||
}
|
||||
if (!isCode(error, 'EEXIST')) throw error;
|
||||
}
|
||||
await this.unlinkIfPresent(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
await this.bestEffortSyncDirectory(directory);
|
||||
return reference;
|
||||
}
|
||||
|
||||
quarantineReference(attemptId: string): string {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return path.posix.join(
|
||||
'.quarantine',
|
||||
attemptId.slice(0, 2),
|
||||
`${attemptId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
async purgeQuarantine(attemptId: string): Promise<boolean> {
|
||||
const target = path.join(this.root, this.quarantineReference(attemptId));
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
await this.bestEffortSyncDirectory(path.dirname(target));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private directory(attemptId: string): string {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return path.join(this.root, attemptId.slice(0, 2));
|
||||
}
|
||||
|
||||
private target(attemptId: string): string {
|
||||
return path.join(this.directory(attemptId), `${attemptId}.json`);
|
||||
}
|
||||
|
||||
private async bestEffortUnlink(value: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(value);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) {
|
||||
// Temp cleanup is diagnostic-only; the immutable final fact wins.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async unlinkIfPresent(value: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(value);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async pathExists(value: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortSyncDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Some supported filesystems cannot fsync directories. The receipt
|
||||
// remains restart-safe, while power-loss durability is best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { assertCompletionReceiptId } from '../../domain/completionReceipt';
|
||||
import type {
|
||||
CompletionReceiptDirectoryEntry,
|
||||
CompletionReceiptDirectoryEntryKind,
|
||||
CompletionReceiptOrphanDirectory,
|
||||
CompletionReceiptOrphanQuarantineResult,
|
||||
CompletionReceiptShardSnapshot,
|
||||
} from '../../ports/completionReceiptOrphanMaintenance';
|
||||
|
||||
const SHARD_PATTERN = /^[0-9a-f]{2}$/;
|
||||
const TEMPORARY_PATTERN = /^\.([0-9a-f-]{36})\.[0-9a-f]{32}\.tmp$/;
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
function receiptAttemptId(name: string): string | undefined {
|
||||
if (!name.endsWith('.json')) return undefined;
|
||||
const attemptId = name.slice(0, -'.json'.length);
|
||||
try {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return attemptId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryAttemptId(name: string): string | undefined {
|
||||
const match = TEMPORARY_PATTERN.exec(name);
|
||||
if (!match) return undefined;
|
||||
try {
|
||||
assertCompletionReceiptId(match[1], 'attemptId');
|
||||
return match[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKind(
|
||||
shard: string,
|
||||
name: string,
|
||||
regularFile: boolean,
|
||||
): { kind: CompletionReceiptDirectoryEntryKind; attemptId?: string } {
|
||||
if (!regularFile) return { kind: 'unsafe' };
|
||||
const finalAttemptId = receiptAttemptId(name);
|
||||
if (finalAttemptId && finalAttemptId.startsWith(shard)) {
|
||||
return { kind: 'receipt', attemptId: finalAttemptId };
|
||||
}
|
||||
const tempAttemptId = temporaryAttemptId(name);
|
||||
if (tempAttemptId && tempAttemptId.startsWith(shard)) {
|
||||
return { kind: 'temporary', attemptId: tempAttemptId };
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
function filesystemIdentity(
|
||||
stat: Awaited<ReturnType<typeof fs.lstat>>,
|
||||
): string {
|
||||
return [stat.dev, stat.ino, stat.size, stat.mtimeMs].join(':');
|
||||
}
|
||||
|
||||
export class CompletionReceiptOrphanFileDirectory
|
||||
implements CompletionReceiptOrphanDirectory
|
||||
{
|
||||
constructor(private readonly root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new RangeError(
|
||||
'Completion receipt orphan root must be an absolute path containing no NUL',
|
||||
);
|
||||
}
|
||||
if (path.resolve(root) === path.parse(path.resolve(root)).root) {
|
||||
throw new RangeError(
|
||||
'Completion receipt orphan root must not be a filesystem root',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspectShard(
|
||||
shard: string,
|
||||
maxEntries: number,
|
||||
): Promise<CompletionReceiptShardSnapshot> {
|
||||
if (!SHARD_PATTERN.test(shard)) {
|
||||
throw new RangeError(
|
||||
'Completion receipt shard must be two lowercase hex digits',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
maxEntries > 64
|
||||
) {
|
||||
throw new RangeError('maxEntries must be between 1 and 64');
|
||||
}
|
||||
const directoryPath = await this.resolveShardDirectory(shard);
|
||||
if (!directoryPath) return { shard, entries: [], overflow: false };
|
||||
const directory = await fs.opendir(directoryPath, { bufferSize: 1 });
|
||||
|
||||
const entries: CompletionReceiptDirectoryEntry[] = [];
|
||||
let overflow = false;
|
||||
try {
|
||||
for await (const dirent of directory) {
|
||||
if (entries.length === maxEntries) {
|
||||
overflow = true;
|
||||
break;
|
||||
}
|
||||
const entryPath = path.join(directoryPath, dirent.name);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(entryPath);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) continue;
|
||||
throw error;
|
||||
}
|
||||
const classified = entryKind(shard, dirent.name, stat.isFile());
|
||||
entries.push({
|
||||
shard,
|
||||
name: dirent.name,
|
||||
kind: classified.kind,
|
||||
modifiedAtMs: Math.max(0, Math.trunc(stat.mtimeMs)),
|
||||
sizeBytes: stat.size,
|
||||
filesystemIdentity: filesystemIdentity(stat),
|
||||
...(classified.attemptId ? { attemptId: classified.attemptId } : {}),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await directory.close().catch((error) => {
|
||||
if (!isCode(error, 'ERR_DIR_CLOSED')) throw error;
|
||||
});
|
||||
}
|
||||
return { shard, entries, overflow };
|
||||
}
|
||||
|
||||
async quarantine(
|
||||
entry: CompletionReceiptDirectoryEntry,
|
||||
): Promise<CompletionReceiptOrphanQuarantineResult> {
|
||||
if (
|
||||
!SHARD_PATTERN.test(entry.shard) ||
|
||||
path.basename(entry.name) !== entry.name
|
||||
) {
|
||||
throw new RangeError('Completion receipt orphan entry path is invalid');
|
||||
}
|
||||
if (entry.kind === 'unsafe') return { status: 'changed' };
|
||||
const shardDirectory = await this.resolveShardDirectory(entry.shard);
|
||||
if (!shardDirectory) return { status: 'changed' };
|
||||
const source = path.join(shardDirectory, entry.name);
|
||||
let current: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
current = await fs.lstat(source);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'changed' };
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!current.isFile() ||
|
||||
filesystemIdentity(current) !== entry.filesystemIdentity
|
||||
) {
|
||||
return { status: 'changed' };
|
||||
}
|
||||
|
||||
const digest = createHash('sha256')
|
||||
.update(`${entry.shard}/${entry.name}\0${entry.filesystemIdentity}`)
|
||||
.digest('hex');
|
||||
const reference = path.posix.join(
|
||||
'.orphan-quarantine',
|
||||
entry.shard,
|
||||
`${digest}.entry`,
|
||||
);
|
||||
const canonicalRoot = path.dirname(shardDirectory);
|
||||
const directory = await this.ensureQuarantineDirectory(
|
||||
canonicalRoot,
|
||||
entry.shard,
|
||||
);
|
||||
const target = path.join(directory, `${digest}.entry`);
|
||||
let linkedByThisCall = false;
|
||||
try {
|
||||
await fs.link(source, target);
|
||||
linkedByThisCall = true;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'EEXIST')) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'changed' };
|
||||
throw error;
|
||||
}
|
||||
const targetStat = await fs.lstat(target);
|
||||
const sourceStat = await fs.lstat(source).catch(() => undefined);
|
||||
if (
|
||||
!sourceStat ||
|
||||
targetStat.dev !== sourceStat.dev ||
|
||||
targetStat.ino !== sourceStat.ino
|
||||
) {
|
||||
return { status: 'changed' };
|
||||
}
|
||||
}
|
||||
let verifiedShardDirectory: string | undefined;
|
||||
try {
|
||||
verifiedShardDirectory = await this.resolveShardDirectory(entry.shard);
|
||||
} catch (error) {
|
||||
if (linkedByThisCall) await fs.unlink(target).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const verifiedSource = await fs.lstat(source).catch(() => undefined);
|
||||
if (
|
||||
verifiedShardDirectory !== shardDirectory ||
|
||||
!verifiedSource ||
|
||||
filesystemIdentity(verifiedSource) !== entry.filesystemIdentity
|
||||
) {
|
||||
if (linkedByThisCall) await fs.unlink(target).catch(() => undefined);
|
||||
return { status: 'changed' };
|
||||
}
|
||||
await fs.unlink(source);
|
||||
await this.bestEffortSync(path.dirname(source));
|
||||
await this.bestEffortSync(directory);
|
||||
return { status: 'quarantined', reference };
|
||||
}
|
||||
|
||||
private async resolveShardDirectory(
|
||||
shard: string,
|
||||
): Promise<string | undefined> {
|
||||
let canonicalRoot: string;
|
||||
try {
|
||||
canonicalRoot = await fs.realpath(this.root);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
const candidate = path.join(canonicalRoot, shard);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(candidate);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Completion receipt shard ${shard} is not a safe directory`,
|
||||
);
|
||||
}
|
||||
const canonicalDirectory = await fs.realpath(candidate);
|
||||
if (
|
||||
path.dirname(canonicalDirectory) !== canonicalRoot ||
|
||||
path.basename(canonicalDirectory) !== shard
|
||||
) {
|
||||
throw new Error(`Completion receipt shard ${shard} escapes its root`);
|
||||
}
|
||||
return canonicalDirectory;
|
||||
}
|
||||
|
||||
private async ensureQuarantineDirectory(
|
||||
canonicalRoot: string,
|
||||
shard: string,
|
||||
): Promise<string> {
|
||||
const quarantineRoot = path.join(canonicalRoot, '.orphan-quarantine');
|
||||
await this.ensurePrivateDirectory(quarantineRoot, canonicalRoot);
|
||||
const directory = path.join(quarantineRoot, shard);
|
||||
await this.ensurePrivateDirectory(directory, quarantineRoot);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private async ensurePrivateDirectory(
|
||||
directory: string,
|
||||
expectedParent: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(directory, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'EEXIST')) throw error;
|
||||
}
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
'Completion receipt quarantine path is not a safe directory',
|
||||
);
|
||||
}
|
||||
const canonicalDirectory = await fs.realpath(directory);
|
||||
if (path.dirname(canonicalDirectory) !== expectedParent) {
|
||||
throw new Error('Completion receipt quarantine path escapes its root');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
private async bestEffortSync(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Directory fsync is unavailable on some supported filesystems.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { normalizeLocalArtifactReadRange } from '../../domain/artifactRead';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type {
|
||||
AvailableLocalArtifactByteRange,
|
||||
LocalArtifactByteRangeReadResult,
|
||||
LocalArtifactByteRangeReader as LocalArtifactByteRangeReaderPort,
|
||||
} from '../../ports/localArtifactByteRangeReader';
|
||||
|
||||
export class UnsafeLocalArtifactReadTargetError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact read target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactReadTargetError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactByteRangeReader
|
||||
implements LocalArtifactByteRangeReaderPort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact read root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async read(
|
||||
logArtifactId: string,
|
||||
requestedRange: Parameters<LocalArtifactByteRangeReaderPort['read']>[1],
|
||||
): Promise<LocalArtifactByteRangeReadResult> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const range = normalizeLocalArtifactReadRange(requestedRange);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory)))
|
||||
return { status: 'missing' };
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return { status: 'missing' };
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size < 0) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
const start = Math.min(range.offset, stat.size);
|
||||
const expected = Math.min(range.length, stat.size - start);
|
||||
const content = Buffer.allocUnsafe(expected);
|
||||
let read = 0;
|
||||
while (read < expected) {
|
||||
const result = await handle.read(
|
||||
content,
|
||||
read,
|
||||
expected - read,
|
||||
start + read,
|
||||
);
|
||||
if (result.bytesRead < 1) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
read += result.bytesRead;
|
||||
}
|
||||
const endExclusive = start + expected;
|
||||
const result: AvailableLocalArtifactByteRange = {
|
||||
status: 'available',
|
||||
content,
|
||||
start,
|
||||
endExclusive,
|
||||
totalBytes: stat.size,
|
||||
...(endExclusive < stat.size ? { nextOffset: endExclusive } : {}),
|
||||
};
|
||||
return Object.freeze(result);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactReadTargetError) throw error;
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactReadTargetError) throw error;
|
||||
throw new UnsafeLocalArtifactReadTargetError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { constants, type Stats } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES } from '../../domain/localArtifactTruncation';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type {
|
||||
LocalArtifactFileRetirementResult,
|
||||
LocalArtifactFileRetirementStore as LocalArtifactFileRetirementStorePort,
|
||||
} from '../../ports/localArtifactFileRetirementStore';
|
||||
import { localArtifactTruncationFactFileName } from './localArtifactTruncationFactStore';
|
||||
|
||||
export class UnsafeLocalArtifactRetirementError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact retirement target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactRetirementError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactFileRetirementStore
|
||||
implements LocalArtifactFileRetirementStorePort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact retirement root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async retire(
|
||||
logArtifactId: string,
|
||||
): Promise<LocalArtifactFileRetirementResult> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory))) {
|
||||
return Object.freeze({
|
||||
disposition: 'already_absent',
|
||||
bytesReclaimed: 0,
|
||||
});
|
||||
}
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
const fifo = path.join(directory, `.${logArtifactId}.log.fifo`);
|
||||
const truncation = path.join(
|
||||
directory,
|
||||
localArtifactTruncationFactFileName(logArtifactId),
|
||||
);
|
||||
const truncationTemporary = path.join(
|
||||
directory,
|
||||
`.${logArtifactId}.log.truncated.tmp`,
|
||||
);
|
||||
const [targetStat, fifoStat, truncationStat, truncationTemporaryStat] =
|
||||
await Promise.all([
|
||||
this.lstat(target),
|
||||
this.lstat(fifo),
|
||||
this.lstat(truncation),
|
||||
this.lstat(truncationTemporary),
|
||||
]);
|
||||
if (targetStat && (!targetStat.isFile() || targetStat.isSymbolicLink())) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
if (
|
||||
targetStat &&
|
||||
(!Number.isSafeInteger(targetStat.size) || targetStat.size < 0)
|
||||
) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
if (fifoStat && (!fifoStat.isFIFO() || fifoStat.isSymbolicLink())) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
for (const stat of [truncationStat, truncationTemporaryStat]) {
|
||||
if (
|
||||
stat &&
|
||||
(!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES)
|
||||
) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
let targetDeleted = false;
|
||||
let bytesReclaimed = 0;
|
||||
if (targetStat) {
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
targetDeleted = true;
|
||||
bytesReclaimed = targetStat.size;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
let auxiliaryRemoved = false;
|
||||
for (const [auxiliary, stat] of [
|
||||
[fifo, fifoStat],
|
||||
[truncation, truncationStat],
|
||||
[truncationTemporary, truncationTemporaryStat],
|
||||
] as const) {
|
||||
if (!stat) continue;
|
||||
try {
|
||||
await fs.unlink(auxiliary);
|
||||
auxiliaryRemoved = true;
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
if (targetDeleted || auxiliaryRemoved) {
|
||||
await this.syncDirectory(directory);
|
||||
}
|
||||
if (!targetDeleted) {
|
||||
return Object.freeze({
|
||||
disposition: 'already_absent',
|
||||
bytesReclaimed: 0,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
disposition: 'deleted',
|
||||
bytesReclaimed,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactRetirementError) throw error;
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactRetirementError) throw error;
|
||||
throw new UnsafeLocalArtifactRetirementError();
|
||||
}
|
||||
}
|
||||
|
||||
private async lstat(value: string): Promise<Stats | null> {
|
||||
try {
|
||||
return await fs.lstat(value);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await fs.open(
|
||||
directory,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
decodeLocalArtifactTruncationFact,
|
||||
MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES,
|
||||
type LocalArtifactTruncationFact,
|
||||
} from '../../domain/localArtifactTruncation';
|
||||
import { assertLocalExecutionArtifactId } from '../../domain/localExecutionArtifact';
|
||||
import type { LocalArtifactTruncationFactStore as LocalArtifactTruncationFactStorePort } from '../../ports/localArtifactTruncationFactStore';
|
||||
|
||||
export function localArtifactTruncationFactFileName(
|
||||
logArtifactId: string,
|
||||
): string {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
return `.${logArtifactId}.log.truncated.json`;
|
||||
}
|
||||
|
||||
export class UnsafeLocalArtifactTruncationFactError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact truncation fact target is unsafe');
|
||||
this.name = 'UnsafeLocalArtifactTruncationFactError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalArtifactTruncationFactStore
|
||||
implements LocalArtifactTruncationFactStorePort
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact truncation root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
async read(
|
||||
logArtifactId: string,
|
||||
): Promise<Readonly<LocalArtifactTruncationFact> | null> {
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const directory = path.join(this.root, logArtifactId.slice(6, 8));
|
||||
await this.assertDirectory(this.root);
|
||||
if (!(await this.optionalDirectory(directory))) return null;
|
||||
const target = path.join(
|
||||
directory,
|
||||
localArtifactTruncationFactFileName(logArtifactId),
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return null;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_LOCAL_ARTIFACT_TRUNCATION_FACT_BYTES
|
||||
) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
const fact = decodeLocalArtifactTruncationFact(await handle.readFile());
|
||||
if (fact.logArtifactId !== logArtifactId) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
return fact;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertDirectory(value: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalArtifactTruncationFactError) throw error;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
}
|
||||
|
||||
private async optionalDirectory(value: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(value);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
if (error instanceof UnsafeLocalArtifactTruncationFactError) throw error;
|
||||
throw new UnsafeLocalArtifactTruncationFactError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type {
|
||||
ExecutionOutputChunk,
|
||||
ExecutionOutputSink,
|
||||
} from '../../domain/execution';
|
||||
import {
|
||||
LocalArtifactCapacityUnavailableError,
|
||||
LocalArtifactQuotaExceededError,
|
||||
normalizeLocalArtifactCapacityPolicy,
|
||||
type LocalArtifactCapacityPolicy,
|
||||
} from '../../domain/localArtifactCapacity';
|
||||
import {
|
||||
localExecutionArtifactId,
|
||||
assertLocalExecutionArtifactId,
|
||||
} from '../../domain/localExecutionArtifact';
|
||||
import type { RunDispatchCandidate } from '../../domain/runDispatchCandidate';
|
||||
import type {
|
||||
LocalExecutionArtifactAllocator,
|
||||
PreparedLocalExecutionArtifact,
|
||||
} from '../../ports/localExecutionArtifactAllocator';
|
||||
import type { LocalArtifactCapacityProbe } from '../../ports/localArtifactCapacityProbe';
|
||||
import { LocalFileSystemCapacityProbe } from './localFileSystemCapacityProbe';
|
||||
import { enableDurableLocalProcessOutput } from '../local-process/durableLocalProcessOutput';
|
||||
|
||||
async function privateDirectory(directory: string): Promise<void> {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new TypeError('Local execution artifact directory is unsafe');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
class LocalFileExecutionOutput implements ExecutionOutputSink {
|
||||
private pending: Promise<unknown> = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
private remainingBytes: number;
|
||||
|
||||
constructor(
|
||||
private readonly file: fs.FileHandle,
|
||||
maximumBytes: number,
|
||||
existingBytes: number,
|
||||
) {
|
||||
this.remainingBytes = maximumBytes - existingBytes;
|
||||
}
|
||||
|
||||
write(output: ExecutionOutputChunk): Promise<void> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error('Local execution artifact is closed'));
|
||||
}
|
||||
const chunk = Buffer.from(output.chunk);
|
||||
const operation = this.pending.then(async () => {
|
||||
if (this.remainingBytes <= 0) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
const accepted = chunk.subarray(
|
||||
0,
|
||||
Math.min(chunk.length, this.remainingBytes),
|
||||
);
|
||||
let written = 0;
|
||||
while (written < accepted.length) {
|
||||
const result = await this.file.write(accepted.subarray(written));
|
||||
if (result.bytesWritten < 1) {
|
||||
throw new Error('Local execution artifact write made no progress');
|
||||
}
|
||||
written += result.bytesWritten;
|
||||
this.remainingBytes -= result.bytesWritten;
|
||||
}
|
||||
if (accepted.length !== chunk.length) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
});
|
||||
this.pending = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
await this.pending.catch(() => undefined);
|
||||
await this.file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalFileExecutionArtifactAllocator
|
||||
implements LocalExecutionArtifactAllocator
|
||||
{
|
||||
private readonly artifactRoot: string;
|
||||
private readonly completionReceiptRoot: string;
|
||||
private readonly policy: Readonly<LocalArtifactCapacityPolicy>;
|
||||
private readonly capacity: LocalArtifactCapacityProbe;
|
||||
|
||||
constructor(
|
||||
artifactRoot: string,
|
||||
completionReceiptRoot: string,
|
||||
policy: LocalArtifactCapacityPolicy,
|
||||
capacity: LocalArtifactCapacityProbe = new LocalFileSystemCapacityProbe(),
|
||||
) {
|
||||
if (
|
||||
!path.isAbsolute(artifactRoot) ||
|
||||
artifactRoot.includes('\0') ||
|
||||
!path.isAbsolute(completionReceiptRoot) ||
|
||||
completionReceiptRoot.includes('\0')
|
||||
) {
|
||||
throw new TypeError('Local execution artifact roots must be absolute');
|
||||
}
|
||||
this.artifactRoot = path.resolve(artifactRoot);
|
||||
this.completionReceiptRoot = path.resolve(completionReceiptRoot);
|
||||
this.policy = normalizeLocalArtifactCapacityPolicy(policy);
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
async prepare(
|
||||
candidate: Readonly<RunDispatchCandidate>,
|
||||
): Promise<PreparedLocalExecutionArtifact> {
|
||||
const logArtifactId = localExecutionArtifactId(candidate);
|
||||
assertLocalExecutionArtifactId(logArtifactId);
|
||||
const shard = logArtifactId.slice('local-'.length, 'local-'.length + 2);
|
||||
const directory = path.join(this.artifactRoot, shard);
|
||||
await privateDirectory(this.artifactRoot);
|
||||
const capacity = await this.capacity.inspect(this.artifactRoot);
|
||||
const requiredBytes =
|
||||
BigInt(this.policy.minimumFreeBytes) +
|
||||
BigInt(this.policy.maximumAttemptBytes);
|
||||
if (capacity.availableBytes < requiredBytes) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
await privateDirectory(directory);
|
||||
await privateDirectory(this.completionReceiptRoot);
|
||||
const outputFilePath = path.join(directory, `${logArtifactId}.log`);
|
||||
const file = await fs.open(
|
||||
outputFilePath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_APPEND |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError('Local execution artifact target is unsafe');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes
|
||||
) {
|
||||
throw new LocalArtifactQuotaExceededError();
|
||||
}
|
||||
await file.chmod(0o600);
|
||||
const output = new LocalFileExecutionOutput(
|
||||
file,
|
||||
this.policy.maximumAttemptBytes,
|
||||
stat.size,
|
||||
);
|
||||
return {
|
||||
logArtifactId,
|
||||
output: enableDurableLocalProcessOutput(output, {
|
||||
outputFilePath,
|
||||
completionReceiptRoot: this.completionReceiptRoot,
|
||||
maximumBytes: this.policy.maximumAttemptBytes,
|
||||
logArtifactId,
|
||||
}),
|
||||
dispose: () => output.close(),
|
||||
};
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { LocalArtifactCapacityUnavailableError } from '../../domain/localArtifactCapacity';
|
||||
import type {
|
||||
LocalArtifactCapacityProbe,
|
||||
LocalArtifactCapacitySnapshot,
|
||||
LocalArtifactCapacitySource,
|
||||
} from '../../ports/localArtifactCapacityProbe';
|
||||
|
||||
interface BigIntStatFs {
|
||||
bavail: bigint;
|
||||
blocks: bigint;
|
||||
bsize: bigint;
|
||||
}
|
||||
|
||||
export class RootedLocalFileSystemCapacitySource
|
||||
implements LocalArtifactCapacitySource
|
||||
{
|
||||
private readonly root: string;
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
private readonly probe: LocalArtifactCapacityProbe = new LocalFileSystemCapacityProbe(),
|
||||
) {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new TypeError('Local Artifact capacity root must be absolute');
|
||||
}
|
||||
this.root = path.resolve(root);
|
||||
}
|
||||
|
||||
inspect(): Promise<LocalArtifactCapacitySnapshot> {
|
||||
return this.probe.inspect(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
interface StatFsPromises {
|
||||
statfs(value: string, options: { bigint: true }): Promise<BigIntStatFs>;
|
||||
}
|
||||
|
||||
export class LocalFileSystemCapacityProbe
|
||||
implements LocalArtifactCapacityProbe
|
||||
{
|
||||
async inspect(root: string): Promise<LocalArtifactCapacitySnapshot> {
|
||||
if (!path.isAbsolute(root) || root.includes('\0')) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
try {
|
||||
const statfs = (fs as unknown as StatFsPromises).statfs;
|
||||
if (typeof statfs !== 'function') {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
const stat = await statfs.call(fs, root, { bigint: true });
|
||||
const availableBytes = stat.bavail * stat.bsize;
|
||||
const totalBytes = stat.blocks * stat.bsize;
|
||||
if (
|
||||
availableBytes < BigInt(0) ||
|
||||
totalBytes < BigInt(1) ||
|
||||
availableBytes > totalBytes
|
||||
) {
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
return Object.freeze({ availableBytes, totalBytes });
|
||||
} catch (error) {
|
||||
if (error instanceof LocalArtifactCapacityUnavailableError) throw error;
|
||||
throw new LocalArtifactCapacityUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
LocalSecretUnavailableError,
|
||||
assertLocalSecretKeyId,
|
||||
} from '../../domain/localSecret';
|
||||
import type {
|
||||
LocalSecretKeyMaterial,
|
||||
LocalSecretKeyProvider,
|
||||
} from '../../ports/localSecretKeyProvider';
|
||||
|
||||
const MAX_KEYRING_BYTES = 16 * 1024;
|
||||
const MAX_KEY_COUNT = 16;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
interface LocalSecretKeyringManifest {
|
||||
version: 1;
|
||||
activeKeyId: string;
|
||||
keys: Record<string, string>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
return (
|
||||
actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function parseManifest(value: Buffer): LocalSecretKeyringManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.toString('utf8'));
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!exactKeys(parsed, ['activeKeyId', 'keys', 'version'])
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
!record.keys ||
|
||||
typeof record.keys !== 'object' ||
|
||||
Array.isArray(record.keys)
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
assertLocalSecretKeyId(record.activeKeyId as string);
|
||||
const entries = Object.entries(record.keys as Record<string, unknown>);
|
||||
if (entries.length < 1 || entries.length > MAX_KEY_COUNT) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const keys: Record<string, string> = Object.create(null);
|
||||
for (const [keyId, encoded] of entries) {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
const decoded =
|
||||
typeof encoded === 'string'
|
||||
? Buffer.from(encoded, 'base64url')
|
||||
: Buffer.alloc(0);
|
||||
if (
|
||||
typeof encoded !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(encoded) ||
|
||||
decoded.length !== 32 ||
|
||||
decoded.toString('base64url') !== encoded
|
||||
) {
|
||||
decoded.fill(0);
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
decoded.fill(0);
|
||||
keys[keyId] = encoded;
|
||||
}
|
||||
if (!keys[record.activeKeyId as string]) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
activeKeyId: record.activeKeyId as string,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalSecretKeyringFileProvider implements LocalSecretKeyProvider {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
if (!path.isAbsolute(filePath) || filePath.includes('\0')) {
|
||||
throw new TypeError('Local Secret keyring path must be absolute');
|
||||
}
|
||||
this.filePath = path.resolve(filePath);
|
||||
}
|
||||
|
||||
async active(): Promise<LocalSecretKeyMaterial> {
|
||||
const manifest = await this.read();
|
||||
return this.material(
|
||||
manifest,
|
||||
manifest.activeKeyId,
|
||||
) as LocalSecretKeyMaterial;
|
||||
}
|
||||
|
||||
async resolve(keyId: string): Promise<LocalSecretKeyMaterial | null> {
|
||||
try {
|
||||
assertLocalSecretKeyId(keyId);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
const manifest = await this.read();
|
||||
return this.material(manifest, keyId);
|
||||
}
|
||||
|
||||
private material(
|
||||
manifest: LocalSecretKeyringManifest,
|
||||
keyId: string,
|
||||
): LocalSecretKeyMaterial | null {
|
||||
const encoded = manifest.keys[keyId];
|
||||
return encoded
|
||||
? Object.freeze({
|
||||
keyId,
|
||||
key: Uint8Array.from(Buffer.from(encoded, 'base64url')),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
private async read(): Promise<LocalSecretKeyringManifest> {
|
||||
let file: fs.FileHandle | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
file = await fs.open(
|
||||
this.filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await file.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_KEYRING_BYTES
|
||||
) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
contents = await file.readFile();
|
||||
if (contents.length !== stat.size) {
|
||||
throw new LocalSecretUnavailableError();
|
||||
}
|
||||
return parseManifest(contents);
|
||||
} catch {
|
||||
throw new LocalSecretUnavailableError();
|
||||
} finally {
|
||||
contents?.fill(0);
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
createManualPrimaryRuntimeReceipt,
|
||||
MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE,
|
||||
MAX_MANUAL_PRIMARY_RUNTIME_RECEIPT_BYTES,
|
||||
parseManualPrimaryRuntimeReceipt,
|
||||
transitionManualPrimaryRuntimeReceipt,
|
||||
type ManualPrimaryRuntimeProcessIdentity,
|
||||
type ManualPrimaryRuntimeReceipt,
|
||||
type ManualPrimaryRuntimeReceiptState,
|
||||
} from '../../domain/manualPrimaryRuntimeReceipt';
|
||||
import type { ManualPrimaryRuntimeReceiptLifecycle } from '../../ports/manualPrimaryRuntimeReceipt';
|
||||
import type { RuntimeRolloutLoadAudit } from '../../ports/runtimeRolloutLoader';
|
||||
import {
|
||||
LinuxProcProcessIdentityProvider,
|
||||
type LocalProcessIdentityProvider,
|
||||
} from '../local-process/localProcessIdentity';
|
||||
|
||||
export interface ManualPrimaryRuntimeReceiptStoreOptions {
|
||||
clock?: { now(): number };
|
||||
identityProvider?: LocalProcessIdentityProvider;
|
||||
platform?: NodeJS.Platform;
|
||||
pid?: number;
|
||||
randomId?: () => string;
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
export class ManualPrimaryRuntimeReceiptConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ManualPrimaryRuntimeReceiptConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManualPrimaryRuntimeReceiptStore
|
||||
implements ManualPrimaryRuntimeReceiptLifecycle
|
||||
{
|
||||
private readonly target: string;
|
||||
private readonly clock: { now(): number };
|
||||
private readonly identityProvider: LocalProcessIdentityProvider;
|
||||
private readonly platform: NodeJS.Platform;
|
||||
private readonly pid: number;
|
||||
private readonly randomId: () => string;
|
||||
private current?: ManualPrimaryRuntimeReceipt;
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly profile: 'edge' | 'standalone',
|
||||
options: ManualPrimaryRuntimeReceiptStoreOptions = {},
|
||||
) {
|
||||
if (!path.isAbsolute(root)) {
|
||||
throw new TypeError(
|
||||
'Manual Primary runtime receipt root must be absolute',
|
||||
);
|
||||
}
|
||||
this.target = path.join(root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE);
|
||||
this.clock = options.clock ?? { now: Date.now };
|
||||
this.identityProvider =
|
||||
options.identityProvider ?? new LinuxProcProcessIdentityProvider();
|
||||
this.platform = options.platform ?? process.platform;
|
||||
this.pid = options.pid ?? process.pid;
|
||||
this.randomId = options.randomId ?? (() => randomBytes(16).toString('hex'));
|
||||
}
|
||||
|
||||
async activated(audit: RuntimeRolloutLoadAudit): Promise<void> {
|
||||
if (
|
||||
typeof audit.revision !== 'string' ||
|
||||
typeof audit.sourceSha256 !== 'string'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Accepted rollout audit lacks durable revision or source digest',
|
||||
);
|
||||
}
|
||||
await this.assertRoot();
|
||||
const processIdentity = await this.captureProcessIdentity();
|
||||
const existing = await this.read();
|
||||
await this.assertReplaceable(existing);
|
||||
const receipt = createManualPrimaryRuntimeReceipt({
|
||||
activationId: this.randomId(),
|
||||
profile: this.profile,
|
||||
revision: audit.revision,
|
||||
rolloutSourceSha256: audit.sourceSha256,
|
||||
activatedAtMs: this.now(),
|
||||
process: processIdentity,
|
||||
});
|
||||
await this.write(receipt);
|
||||
this.current = receipt;
|
||||
}
|
||||
|
||||
async stopping(): Promise<void> {
|
||||
await this.transition('stopping');
|
||||
}
|
||||
|
||||
async stopped(): Promise<void> {
|
||||
await this.transition('stopped');
|
||||
}
|
||||
|
||||
async failed(): Promise<void> {
|
||||
if (this.current === undefined) return;
|
||||
await this.transition('failed');
|
||||
}
|
||||
|
||||
private async transition(
|
||||
state: Exclude<ManualPrimaryRuntimeReceiptState, 'active'>,
|
||||
): Promise<void> {
|
||||
if (this.current === undefined) {
|
||||
throw new ManualPrimaryRuntimeReceiptConflictError(
|
||||
'Manual Primary runtime receipt was not activated by this process',
|
||||
);
|
||||
}
|
||||
const observed = await this.read();
|
||||
if (
|
||||
observed === undefined ||
|
||||
observed.activationId !== this.current.activationId ||
|
||||
observed.receiptSha256 !== this.current.receiptSha256
|
||||
) {
|
||||
throw new ManualPrimaryRuntimeReceiptConflictError(
|
||||
'Manual Primary runtime receipt ownership changed',
|
||||
);
|
||||
}
|
||||
const next = transitionManualPrimaryRuntimeReceipt(
|
||||
this.current,
|
||||
state,
|
||||
Math.max(this.current.updatedAtMs, this.now()),
|
||||
);
|
||||
await this.write(next);
|
||||
this.current = next;
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.clock.now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError('Manual Primary runtime receipt clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async captureProcessIdentity(): Promise<ManualPrimaryRuntimeProcessIdentity> {
|
||||
const identity = await this.identityProvider.capture(this.pid);
|
||||
if (identity !== null) {
|
||||
return { kind: 'linux-proc', ...identity };
|
||||
}
|
||||
if (this.platform === 'linux') {
|
||||
throw new Error('Linux process identity is unavailable');
|
||||
}
|
||||
return { kind: 'portable', platform: this.platform, pid: this.pid };
|
||||
}
|
||||
|
||||
private async assertReplaceable(
|
||||
existing: ManualPrimaryRuntimeReceipt | undefined,
|
||||
): Promise<void> {
|
||||
if (
|
||||
existing === undefined ||
|
||||
existing.state === 'stopped' ||
|
||||
existing.state === 'failed'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (existing.process.kind !== 'linux-proc') return;
|
||||
const inspection = await this.identityProvider.inspect(existing.process);
|
||||
if (inspection.status === 'running') {
|
||||
throw new ManualPrimaryRuntimeReceiptConflictError(
|
||||
'Another Manual Primary runtime receipt is still live',
|
||||
);
|
||||
}
|
||||
if (!['exited', 'identity_mismatch'].includes(inspection.status)) {
|
||||
throw new ManualPrimaryRuntimeReceiptConflictError(
|
||||
'Previous Manual Primary runtime identity cannot be disproved',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertRoot(): Promise<void> {
|
||||
const stat = await fs.lstat(this.root);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(typeof process.getuid === 'function' && stat.uid !== process.getuid())
|
||||
) {
|
||||
throw new TypeError('Manual Primary runtime receipt root is unsafe');
|
||||
}
|
||||
}
|
||||
|
||||
private async read(): Promise<ManualPrimaryRuntimeReceipt | undefined> {
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
this.target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
(typeof process.getuid === 'function' &&
|
||||
stat.uid !== process.getuid()) ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_MANUAL_PRIMARY_RUNTIME_RECEIPT_BYTES
|
||||
) {
|
||||
throw new TypeError('Manual Primary runtime receipt file is unsafe');
|
||||
}
|
||||
return parseManualPrimaryRuntimeReceipt(
|
||||
JSON.parse((await handle.readFile()).toString('utf8')),
|
||||
);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async write(receipt: ManualPrimaryRuntimeReceipt): Promise<void> {
|
||||
const temporary = path.join(
|
||||
this.root,
|
||||
`.ql3-runtime-receipt-${this.pid}-${randomBytes(8).toString('hex')}.tmp`,
|
||||
);
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(temporary, 'wx', 0o600);
|
||||
await handle.writeFile(`${JSON.stringify(receipt)}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await fs.rename(temporary, this.target);
|
||||
await this.bestEffortSyncDirectory();
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (!isCode(error, 'ENOENT')) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortSyncDirectory(): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(this.root, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// The atomic state remains valid on filesystems without directory fsync.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
defaultOffRuntimeRolloutPolicy,
|
||||
parseRuntimeRolloutManifest,
|
||||
} from '../../domain/runtimeRolloutManifest';
|
||||
import type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
import {
|
||||
parseLegacyShadowPrimaryGateReceipt,
|
||||
type LegacyShadowPrimaryGateReceipt,
|
||||
} from '../../domain/legacyShadowPrimaryGate';
|
||||
|
||||
export type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
RuntimeRolloutLoadStatus,
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
|
||||
export const MAX_RUNTIME_ROLLOUT_MANIFEST_BYTES = 64 * 1024;
|
||||
export const MAX_RUNTIME_PRIMARY_GATE_RECEIPT_BYTES = 64 * 1024;
|
||||
|
||||
export interface RuntimeRolloutManifestLoaderOptions {
|
||||
clock?: { now(): number };
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function rejected(
|
||||
audit: RuntimeRolloutLoadAudit,
|
||||
reasonCode: NonNullable<RuntimeRolloutLoadAudit['reasonCode']>,
|
||||
): RuntimeRolloutLoadResult {
|
||||
return {
|
||||
status: 'rejected',
|
||||
policy: defaultOffRuntimeRolloutPolicy(),
|
||||
audit: { ...audit, status: 'rejected', reasonCode },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPrimaryGateReceipt(
|
||||
manifestPath: string,
|
||||
receiptFile: string,
|
||||
expectedSha256: string,
|
||||
approvedAtMs: number,
|
||||
): Promise<LegacyShadowPrimaryGateReceipt> {
|
||||
const receiptPath = path.join(path.dirname(manifestPath), receiptFile);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
receiptPath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RUNTIME_PRIMARY_GATE_RECEIPT_BYTES
|
||||
) {
|
||||
throw new TypeError('Primary gate receipt file shape is invalid');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (createHash('sha256').update(bytes).digest('hex') !== expectedSha256) {
|
||||
throw new TypeError('Primary gate receipt digest does not match');
|
||||
}
|
||||
const receipt = parseLegacyShadowPrimaryGateReceipt(
|
||||
JSON.parse(bytes.toString('utf8')),
|
||||
);
|
||||
if (
|
||||
receipt.assessment !== 'eligible' ||
|
||||
receipt.generatedAtMs > approvedAtMs
|
||||
) {
|
||||
throw new TypeError('Primary gate receipt is not eligible for approval');
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRuntimeRolloutManifest(
|
||||
sourcePath: string,
|
||||
options: RuntimeRolloutManifestLoaderOptions = {},
|
||||
): Promise<RuntimeRolloutLoadResult> {
|
||||
if (!path.isAbsolute(sourcePath)) {
|
||||
throw new TypeError('Runtime rollout manifest path must be absolute');
|
||||
}
|
||||
const evaluatedAtMs = (options.clock ?? { now: Date.now }).now();
|
||||
if (!Number.isSafeInteger(evaluatedAtMs) || evaluatedAtMs < 0) {
|
||||
throw new TypeError('Runtime rollout clock returned an invalid timestamp');
|
||||
}
|
||||
const maxBytes = options.maxBytes ?? MAX_RUNTIME_ROLLOUT_MANIFEST_BYTES;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new TypeError('Runtime rollout maxBytes must be a positive integer');
|
||||
}
|
||||
const baseAudit: RuntimeRolloutLoadAudit = {
|
||||
event: 'runtime.rollout_config_evaluated',
|
||||
evaluatedAtMs,
|
||||
sourcePath,
|
||||
status: 'rejected',
|
||||
};
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await fs.readFile(sourcePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
status: 'missing',
|
||||
policy: defaultOffRuntimeRolloutPolicy(),
|
||||
audit: {
|
||||
...baseAudit,
|
||||
status: 'missing',
|
||||
reasonCode: 'FILE_MISSING',
|
||||
},
|
||||
};
|
||||
}
|
||||
return rejected(baseAudit, 'FILE_READ_FAILED');
|
||||
}
|
||||
|
||||
const sourceSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
const hashedAudit = { ...baseAudit, sourceSha256 };
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
return rejected(hashedAudit, 'FILE_TOO_LARGE');
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
return rejected(hashedAudit, 'INVALID_JSON');
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = parseRuntimeRolloutManifest(value, evaluatedAtMs);
|
||||
const status = decision.manifest.enabled ? 'accepted' : 'disabled';
|
||||
let primaryGateReceipt: LegacyShadowPrimaryGateReceipt | undefined;
|
||||
if (decision.manifest.enabled) {
|
||||
try {
|
||||
primaryGateReceipt = await loadPrimaryGateReceipt(
|
||||
sourcePath,
|
||||
decision.manifest.primaryGate.receiptFile,
|
||||
decision.manifest.primaryGate.receiptSha256,
|
||||
decision.manifest.approvedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return rejected(
|
||||
hashedAudit,
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? 'PRIMARY_GATE_READ_FAILED'
|
||||
: 'PRIMARY_GATE_INVALID',
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
policy: decision.policy,
|
||||
manifest: decision.manifest,
|
||||
...(primaryGateReceipt === undefined ? {} : { primaryGateReceipt }),
|
||||
audit: {
|
||||
...hashedAudit,
|
||||
status,
|
||||
revision: decision.manifest.revision,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return rejected(hashedAudit, 'INVALID_MANIFEST');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_ENTRIES,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_PAGE_SIZE,
|
||||
MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES,
|
||||
cloneWorkerExecutionOfferJournalRecord,
|
||||
parseWorkerExecutionOfferJournalRecord,
|
||||
serializeWorkerExecutionOfferJournalRecord,
|
||||
type WorkerExecutionOfferJournalRecord,
|
||||
} from '../../domain/workerExecutionOffer';
|
||||
import { assertRunDispatchOfferId } from '../../domain/runDispatchOffer';
|
||||
import type {
|
||||
WorkerExecutionOfferJournal,
|
||||
WorkerExecutionOfferJournalCreateResult,
|
||||
WorkerExecutionOfferJournalPage,
|
||||
} from '../../ports/workerExecutionOfferJournal';
|
||||
import type {
|
||||
WorkerExecutionOfferJournalOwnership,
|
||||
WorkerExecutionOfferJournalOwnershipState,
|
||||
} from '../../ports/workerExecutionOfferJournalOwnership';
|
||||
|
||||
const JOURNAL_FILE_PATTERN = /^([0-9a-f]{64})\.json$/;
|
||||
|
||||
export const MIN_WORKER_OFFER_JOURNAL_LOCK_STALE_MS = 5_000;
|
||||
export const MAX_WORKER_OFFER_JOURNAL_LOCK_STALE_MS = 5 * 60_000;
|
||||
|
||||
export interface WorkerExecutionOfferLockProvider {
|
||||
acquire(options: {
|
||||
root: string;
|
||||
lockfilePath: string;
|
||||
staleMs: number;
|
||||
updateMs: number;
|
||||
onCompromised(error: Error): void;
|
||||
}): Promise<() => Promise<void>>;
|
||||
}
|
||||
|
||||
const properLockProvider: WorkerExecutionOfferLockProvider = {
|
||||
acquire(options) {
|
||||
return lock(options.root, {
|
||||
stale: options.staleMs,
|
||||
update: options.updateMs,
|
||||
retries: 0,
|
||||
realpath: true,
|
||||
lockfilePath: options.lockfilePath,
|
||||
onCompromised: options.onCompromised,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export class WorkerExecutionOfferJournalCapacityError extends Error {
|
||||
constructor(readonly maximumEntries: number) {
|
||||
super(`Worker execution offer journal reached ${maximumEntries} entries`);
|
||||
this.name = 'WorkerExecutionOfferJournalCapacityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalRevisionError extends Error {
|
||||
constructor(readonly offerId: string) {
|
||||
super(`Worker execution offer journal revision changed for ${offerId}`);
|
||||
this.name = 'WorkerExecutionOfferJournalRevisionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalNotFoundError extends Error {
|
||||
constructor(readonly offerId: string) {
|
||||
super(`Worker execution offer journal entry ${offerId} was not found`);
|
||||
this.name = 'WorkerExecutionOfferJournalNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionOfferJournalOwnershipError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'already_owned' | 'not_owned' | 'compromised',
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(`Worker execution offer journal ownership failed: ${reason}`);
|
||||
this.name = 'WorkerExecutionOfferJournalOwnershipError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
function assertIntegerBetween(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-local restart journal. One bounded file per offer avoids a database or
|
||||
* sidecar on edge devices. The Worker runtime must exclusively own this root.
|
||||
*/
|
||||
export class WorkerExecutionOfferFileJournal
|
||||
implements WorkerExecutionOfferJournal, WorkerExecutionOfferJournalOwnership
|
||||
{
|
||||
private readonly maximumEntries: number;
|
||||
private readonly ownershipStaleMs: number;
|
||||
private readonly lockProvider: WorkerExecutionOfferLockProvider;
|
||||
private readonly onOwnershipCompromised?: (error: Error) => void;
|
||||
private ownerState: WorkerExecutionOfferJournalOwnershipState = 'unowned';
|
||||
private releaseOwner?: () => Promise<void>;
|
||||
private ownershipError?: Error;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
options: {
|
||||
maximumEntries?: number;
|
||||
ownershipStaleMs?: number;
|
||||
lockProvider?: WorkerExecutionOfferLockProvider;
|
||||
onOwnershipCompromised?: (error: Error) => void;
|
||||
} = {},
|
||||
) {
|
||||
if (!path.isAbsolute(root)) {
|
||||
throw new RangeError(
|
||||
'Worker execution offer journal root must be absolute',
|
||||
);
|
||||
}
|
||||
this.maximumEntries = options.maximumEntries ?? 64;
|
||||
assertIntegerBetween(
|
||||
'maximumEntries',
|
||||
this.maximumEntries,
|
||||
1,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_ENTRIES,
|
||||
);
|
||||
this.ownershipStaleMs = options.ownershipStaleMs ?? 30_000;
|
||||
assertIntegerBetween(
|
||||
'ownershipStaleMs',
|
||||
this.ownershipStaleMs,
|
||||
MIN_WORKER_OFFER_JOURNAL_LOCK_STALE_MS,
|
||||
MAX_WORKER_OFFER_JOURNAL_LOCK_STALE_MS,
|
||||
);
|
||||
this.lockProvider = options.lockProvider ?? properLockProvider;
|
||||
this.onOwnershipCompromised = options.onOwnershipCompromised;
|
||||
}
|
||||
|
||||
ownershipState(): WorkerExecutionOfferJournalOwnershipState {
|
||||
return this.ownerState;
|
||||
}
|
||||
|
||||
async acquireOwnership(): Promise<'acquired' | 'already_owned'> {
|
||||
if (this.ownerState === 'owned') return 'already_owned';
|
||||
if (this.ownerState === 'releasing') {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError('not_owned');
|
||||
}
|
||||
if (this.ownerState === 'compromised') {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
'compromised',
|
||||
this.ownershipError,
|
||||
);
|
||||
}
|
||||
await this.ensureRoot();
|
||||
try {
|
||||
const release = await this.lockProvider.acquire({
|
||||
root: this.root,
|
||||
lockfilePath: path.join(this.root, '.owner.lock'),
|
||||
staleMs: this.ownershipStaleMs,
|
||||
updateMs: Math.max(1_000, Math.floor(this.ownershipStaleMs / 2)),
|
||||
onCompromised: (error) => this.compromiseOwnership(error),
|
||||
});
|
||||
this.releaseOwner = release;
|
||||
this.ownerState = 'owned';
|
||||
return 'acquired';
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ELOCKED')) {
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
'already_owned',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseOwnership(): Promise<'released' | 'not_owned' | 'compromised'> {
|
||||
if (this.ownerState === 'unowned') return 'not_owned';
|
||||
if (this.ownerState === 'compromised') return 'compromised';
|
||||
if (this.ownerState === 'releasing') return 'not_owned';
|
||||
while (true) {
|
||||
const pending = this.mutationTail;
|
||||
await pending;
|
||||
if (pending === this.mutationTail) break;
|
||||
}
|
||||
const ownerStateAfterMutations = this.ownershipState();
|
||||
if (ownerStateAfterMutations === 'compromised') return 'compromised';
|
||||
if (ownerStateAfterMutations !== 'owned') return 'not_owned';
|
||||
const release = this.releaseOwner;
|
||||
if (!release) {
|
||||
this.compromiseOwnership(
|
||||
new Error('Worker offer journal owner release capability is missing'),
|
||||
);
|
||||
return 'compromised';
|
||||
}
|
||||
this.ownerState = 'releasing';
|
||||
try {
|
||||
await release();
|
||||
this.releaseOwner = undefined;
|
||||
this.ownerState = 'unowned';
|
||||
return 'released';
|
||||
} catch (error) {
|
||||
const compromised =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Worker offer journal owner release failed');
|
||||
this.compromiseOwnership(compromised);
|
||||
throw new WorkerExecutionOfferJournalOwnershipError('compromised', error);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): Promise<WorkerExecutionOfferJournalCreateResult> {
|
||||
this.assertOwned();
|
||||
const candidate = cloneWorkerExecutionOfferJournalRecord(record);
|
||||
if (candidate.revision !== 0 || candidate.state !== 'accepted') {
|
||||
throw new TypeError(
|
||||
'A new Worker offer journal entry must be accepted at revision zero',
|
||||
);
|
||||
}
|
||||
return this.serializeMutation(async () => {
|
||||
const target = this.target(candidate.offer.offerId);
|
||||
if (await this.exists(target)) return 'exists';
|
||||
const names = await this.entryNames();
|
||||
if (names.length >= this.maximumEntries) {
|
||||
throw new WorkerExecutionOfferJournalCapacityError(this.maximumEntries);
|
||||
}
|
||||
const temporary = this.temporary(candidate.offer.offerId);
|
||||
try {
|
||||
await this.writeTemporary(temporary, candidate);
|
||||
this.assertOwned();
|
||||
await fs.link(temporary, target);
|
||||
await this.bestEffortSyncDirectory();
|
||||
return 'created';
|
||||
} catch (error) {
|
||||
if (isCode(error, 'EEXIST')) return 'exists';
|
||||
throw error;
|
||||
} finally {
|
||||
await this.bestEffortUnlink(temporary);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async read(
|
||||
offerId: string,
|
||||
): Promise<WorkerExecutionOfferJournalRecord | undefined> {
|
||||
this.assertOwned();
|
||||
assertRunDispatchOfferId(offerId);
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
this.target(offerId),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal entry must be a regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(
|
||||
MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES + 1,
|
||||
);
|
||||
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
||||
if (bytesRead > MAX_WORKER_EXECUTION_OFFER_RECORD_BYTES) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal entry exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
const record = parseWorkerExecutionOfferJournalRecord(
|
||||
bytes.subarray(0, bytesRead),
|
||||
);
|
||||
if (record.offer.offerId !== offerId) {
|
||||
throw new TypeError(
|
||||
'Worker offer journal path and payload do not match',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async replace(
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
expectedRevision: number,
|
||||
): Promise<void> {
|
||||
this.assertOwned();
|
||||
const candidate = cloneWorkerExecutionOfferJournalRecord(record);
|
||||
assertIntegerBetween(
|
||||
'expectedRevision',
|
||||
expectedRevision,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER - 1,
|
||||
);
|
||||
if (candidate.revision !== expectedRevision + 1) {
|
||||
throw new TypeError('Replacement journal revision must increment by one');
|
||||
}
|
||||
await this.serializeMutation(async () => {
|
||||
const current = await this.read(candidate.offer.offerId);
|
||||
if (!current) {
|
||||
throw new WorkerExecutionOfferJournalNotFoundError(
|
||||
candidate.offer.offerId,
|
||||
);
|
||||
}
|
||||
if (current.revision !== expectedRevision) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(
|
||||
candidate.offer.offerId,
|
||||
);
|
||||
}
|
||||
const temporary = this.temporary(candidate.offer.offerId);
|
||||
try {
|
||||
await this.writeTemporary(temporary, candidate);
|
||||
this.assertOwned();
|
||||
await fs.rename(temporary, this.target(candidate.offer.offerId));
|
||||
await this.bestEffortSyncDirectory();
|
||||
} finally {
|
||||
await this.bestEffortUnlink(temporary);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async remove(offerId: string, expectedRevision?: number): Promise<boolean> {
|
||||
this.assertOwned();
|
||||
assertRunDispatchOfferId(offerId);
|
||||
if (expectedRevision !== undefined) {
|
||||
assertIntegerBetween(
|
||||
'expectedRevision',
|
||||
expectedRevision,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
}
|
||||
return this.serializeMutation(async () => {
|
||||
if (expectedRevision !== undefined) {
|
||||
const current = await this.read(offerId);
|
||||
if (!current) return false;
|
||||
if (current.revision !== expectedRevision) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(offerId);
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.assertOwned();
|
||||
await fs.unlink(this.target(offerId));
|
||||
await this.bestEffortSyncDirectory();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
options: { afterOfferId?: string; limit?: number } = {},
|
||||
): Promise<WorkerExecutionOfferJournalPage> {
|
||||
this.assertOwned();
|
||||
if (options.afterOfferId !== undefined) {
|
||||
assertRunDispatchOfferId(options.afterOfferId);
|
||||
}
|
||||
const limit = options.limit ?? 32;
|
||||
assertIntegerBetween(
|
||||
'limit',
|
||||
limit,
|
||||
1,
|
||||
MAX_WORKER_EXECUTION_OFFER_JOURNAL_PAGE_SIZE,
|
||||
);
|
||||
const names = await this.entryNames();
|
||||
const offerIds = names
|
||||
.map((name) => JOURNAL_FILE_PATTERN.exec(name)?.[1])
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.filter(
|
||||
(offerId) =>
|
||||
options.afterOfferId === undefined || offerId > options.afterOfferId,
|
||||
)
|
||||
.sort();
|
||||
const selected = offerIds.slice(0, limit + 1);
|
||||
const hasMore = selected.length > limit;
|
||||
const pageIds = selected.slice(0, limit);
|
||||
const records: WorkerExecutionOfferJournalRecord[] = [];
|
||||
for (const offerId of pageIds) {
|
||||
const record = await this.read(offerId);
|
||||
if (!record) {
|
||||
throw new WorkerExecutionOfferJournalRevisionError(offerId);
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return {
|
||||
records,
|
||||
...(hasMore && pageIds.length
|
||||
? { nextAfterOfferId: pageIds[pageIds.length - 1] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private target(offerId: string): string {
|
||||
assertRunDispatchOfferId(offerId);
|
||||
return path.join(this.root, `${offerId}.json`);
|
||||
}
|
||||
|
||||
private temporary(offerId: string): string {
|
||||
return path.join(
|
||||
this.root,
|
||||
`.${offerId}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await fs.mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(this.root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal root must be a real directory',
|
||||
);
|
||||
}
|
||||
await fs.chmod(this.root, 0o700);
|
||||
}
|
||||
|
||||
private assertOwned(): void {
|
||||
if (this.ownerState === 'owned') return;
|
||||
throw new WorkerExecutionOfferJournalOwnershipError(
|
||||
this.ownerState === 'compromised' ? 'compromised' : 'not_owned',
|
||||
this.ownershipError,
|
||||
);
|
||||
}
|
||||
|
||||
private compromiseOwnership(error: Error): void {
|
||||
this.ownershipError = error;
|
||||
this.releaseOwner = undefined;
|
||||
this.ownerState = 'compromised';
|
||||
try {
|
||||
this.onOwnershipCompromised?.(error);
|
||||
} catch {
|
||||
// Ownership loss must remain visible even if diagnostics fail.
|
||||
}
|
||||
}
|
||||
|
||||
private async entryNames(): Promise<string[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.root);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return [];
|
||||
throw error;
|
||||
}
|
||||
const entries = names.filter((name) => JOURNAL_FILE_PATTERN.test(name));
|
||||
if (entries.length > this.maximumEntries) {
|
||||
throw new WorkerExecutionOfferJournalCapacityError(this.maximumEntries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async writeTemporary(
|
||||
temporary: string,
|
||||
record: WorkerExecutionOfferJournalRecord,
|
||||
): Promise<void> {
|
||||
const serialized = serializeWorkerExecutionOfferJournalRecord(record);
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(temporary, 'wx', 0o600);
|
||||
await handle.writeFile(serialized, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.lstat(target);
|
||||
if (!stat.isFile()) {
|
||||
throw new TypeError(
|
||||
'Worker execution offer journal target must be a regular file',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortUnlink(target: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'ENOENT')) {
|
||||
// Temp cleanup is diagnostic-only; the atomically published record wins.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async bestEffortSyncDirectory(): Promise<void> {
|
||||
try {
|
||||
const handle = await fs.open(this.root, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Some supported filesystems cannot fsync directories.
|
||||
}
|
||||
}
|
||||
|
||||
private serializeMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationTail.then(operation, operation);
|
||||
this.mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
ModelStatic,
|
||||
QueryTypes,
|
||||
Sequelize,
|
||||
Transaction,
|
||||
UniqueConstraintError,
|
||||
} from 'sequelize';
|
||||
import {
|
||||
APPROVAL_REQUEST_TABLE,
|
||||
APPROVED_ACTION_DISPATCH_TABLE,
|
||||
} from '../../../migrations/0020-approval-requests';
|
||||
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from '../../../migrations/0021-approved-action-dispatch-executions';
|
||||
import { DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS } from '../../domain/approvedActionDispatchExecution';
|
||||
import {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestExpiredError,
|
||||
ApprovalRequestNotFoundError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalRequestVersionConflictError,
|
||||
ApprovalUnavailableError,
|
||||
InvalidApprovalValueError,
|
||||
normalizeApprovalActionBinding,
|
||||
normalizeApprovalPolicyFence,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
sameApprovalAction,
|
||||
sameApprovalSubject,
|
||||
type ApprovalRequestRecord,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '../../domain/approvalRequest';
|
||||
import {
|
||||
normalizePolicySubject,
|
||||
type ProjectPolicyFence,
|
||||
} from '../../domain/projectPolicy';
|
||||
import type {
|
||||
ApprovalRequestRepository,
|
||||
ConsumeApprovalRequestCommand,
|
||||
ConsumeApprovalRequestResult,
|
||||
CreateApprovalRequestCommand,
|
||||
CreateApprovalRequestResult,
|
||||
DecideApprovalRequestCommand,
|
||||
DecideApprovalRequestResult,
|
||||
} from '../../ports/approvalRequestRepository';
|
||||
import {
|
||||
PROJECT_ROLE_BINDING_TABLE,
|
||||
PROJECT_TABLE,
|
||||
} from '../../../migrations/0017-project-policy';
|
||||
|
||||
const RETRY_ATTEMPTS = 5;
|
||||
|
||||
interface ApprovalRequestRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
version: number;
|
||||
state: string;
|
||||
permission: string;
|
||||
actionType: string;
|
||||
actionRef: string;
|
||||
actionDigest: string;
|
||||
previewDigest: string;
|
||||
risk: string;
|
||||
requestedByType: string;
|
||||
requestedById: string;
|
||||
requestedAtMs: number | string;
|
||||
expiresAtMs: number | string;
|
||||
decisionId: string | null;
|
||||
decision: string | null;
|
||||
decisionReasonCode: string | null;
|
||||
decidedByType: string | null;
|
||||
decidedById: string | null;
|
||||
decidedAtMs: number | string | null;
|
||||
consumptionId: string | null;
|
||||
dispatchId: string | null;
|
||||
consumedByType: string | null;
|
||||
consumedById: string | null;
|
||||
consumedAtMs: number | string | null;
|
||||
}
|
||||
|
||||
interface ApprovalRequestInstance
|
||||
extends Model<ApprovalRequestRow, ApprovalRequestRow>,
|
||||
ApprovalRequestRow {}
|
||||
|
||||
interface ApprovedActionDispatchRow {
|
||||
id: string;
|
||||
approvalRequestId: string;
|
||||
approvalRequestVersion: number;
|
||||
projectId: string;
|
||||
state: string;
|
||||
permission: string;
|
||||
actionType: string;
|
||||
actionRef: string;
|
||||
actionDigest: string;
|
||||
previewDigest: string;
|
||||
requestedByType: string;
|
||||
requestedById: string;
|
||||
consumedByType: string;
|
||||
consumedById: string;
|
||||
createdAtMs: number | string;
|
||||
}
|
||||
|
||||
interface ApprovedActionDispatchInstance
|
||||
extends Model<ApprovedActionDispatchRow, ApprovedActionDispatchRow>,
|
||||
ApprovedActionDispatchRow {}
|
||||
|
||||
interface PolicyFenceRow {
|
||||
project_version: number | string;
|
||||
binding_version: number | string | null;
|
||||
}
|
||||
|
||||
function defineApprovalRequestModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<ApprovalRequestInstance> {
|
||||
return database.define<ApprovalRequestInstance>(
|
||||
'Ql3ApprovalRequest',
|
||||
{
|
||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
||||
projectId: {
|
||||
field: 'project_id',
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
},
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
||||
actionType: {
|
||||
field: 'action_type',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
actionRef: {
|
||||
field: 'action_ref',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
actionDigest: {
|
||||
field: 'action_digest',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
previewDigest: {
|
||||
field: 'preview_digest',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
risk: { type: DataTypes.STRING(16), allowNull: false },
|
||||
requestedByType: {
|
||||
field: 'requested_by_type',
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
},
|
||||
requestedById: {
|
||||
field: 'requested_by_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
requestedAtMs: {
|
||||
field: 'requested_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
expiresAtMs: {
|
||||
field: 'expires_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
decisionId: {
|
||||
field: 'decision_id',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
decision: { type: DataTypes.STRING(16), allowNull: true },
|
||||
decisionReasonCode: {
|
||||
field: 'decision_reason_code',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
decidedByType: {
|
||||
field: 'decided_by_type',
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: true,
|
||||
},
|
||||
decidedById: {
|
||||
field: 'decided_by_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
decidedAtMs: {
|
||||
field: 'decided_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
consumptionId: {
|
||||
field: 'consumption_id',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
dispatchId: {
|
||||
field: 'dispatch_id',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
consumedByType: {
|
||||
field: 'consumed_by_type',
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: true,
|
||||
},
|
||||
consumedById: {
|
||||
field: 'consumed_by_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
consumedAtMs: {
|
||||
field: 'consumed_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: APPROVAL_REQUEST_TABLE,
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function defineApprovedActionDispatchModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<ApprovedActionDispatchInstance> {
|
||||
return database.define<ApprovedActionDispatchInstance>(
|
||||
'Ql3ApprovedActionDispatch',
|
||||
{
|
||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
||||
approvalRequestId: {
|
||||
field: 'approval_request_id',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
approvalRequestVersion: {
|
||||
field: 'approval_request_version',
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
projectId: {
|
||||
field: 'project_id',
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false,
|
||||
},
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
permission: { type: DataTypes.STRING(255), allowNull: false },
|
||||
actionType: {
|
||||
field: 'action_type',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
actionRef: {
|
||||
field: 'action_ref',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
actionDigest: {
|
||||
field: 'action_digest',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
previewDigest: {
|
||||
field: 'preview_digest',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
requestedByType: {
|
||||
field: 'requested_by_type',
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
},
|
||||
requestedById: {
|
||||
field: 'requested_by_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
consumedByType: {
|
||||
field: 'consumed_by_type',
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
},
|
||||
consumedById: {
|
||||
field: 'consumed_by_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
createdAtMs: {
|
||||
field: 'created_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: APPROVED_ACTION_DISPATCH_TABLE,
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function rowToRequest(
|
||||
row: ApprovalRequestRow,
|
||||
): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
return normalizeApprovalRequestRecord({
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
version: Number(row.version),
|
||||
state: row.state as ApprovalRequestRecord['state'],
|
||||
action: {
|
||||
permission:
|
||||
row.permission as ApprovalRequestRecord['action']['permission'],
|
||||
actionType: row.actionType,
|
||||
actionRef: row.actionRef,
|
||||
actionDigest: row.actionDigest,
|
||||
previewDigest: row.previewDigest,
|
||||
},
|
||||
risk: row.risk as ApprovalRequestRecord['risk'],
|
||||
requestedBy: {
|
||||
type: row.requestedByType as ApprovalRequestRecord['requestedBy']['type'],
|
||||
id: row.requestedById,
|
||||
},
|
||||
requestedAtMs: Number(row.requestedAtMs),
|
||||
expiresAtMs: Number(row.expiresAtMs),
|
||||
decisionId: row.decisionId,
|
||||
decision: row.decision as ApprovalRequestRecord['decision'],
|
||||
decisionReasonCode: row.decisionReasonCode,
|
||||
decidedBy:
|
||||
row.decidedByType === null || row.decidedById === null
|
||||
? null
|
||||
: {
|
||||
type: row.decidedByType as NonNullable<
|
||||
ApprovalRequestRecord['decidedBy']
|
||||
>['type'],
|
||||
id: row.decidedById,
|
||||
},
|
||||
decidedAtMs: row.decidedAtMs === null ? null : Number(row.decidedAtMs),
|
||||
consumptionId: row.consumptionId,
|
||||
dispatchId: row.dispatchId,
|
||||
consumedBy:
|
||||
row.consumedByType === null || row.consumedById === null
|
||||
? null
|
||||
: {
|
||||
type: row.consumedByType as NonNullable<
|
||||
ApprovalRequestRecord['consumedBy']
|
||||
>['type'],
|
||||
id: row.consumedById,
|
||||
},
|
||||
consumedAtMs: row.consumedAtMs === null ? null : Number(row.consumedAtMs),
|
||||
});
|
||||
} catch {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function requestToRow(
|
||||
request: Readonly<ApprovalRequestRecord>,
|
||||
): ApprovalRequestRow {
|
||||
return {
|
||||
id: request.id,
|
||||
projectId: request.projectId,
|
||||
version: request.version,
|
||||
state: request.state,
|
||||
permission: request.action.permission,
|
||||
actionType: request.action.actionType,
|
||||
actionRef: request.action.actionRef,
|
||||
actionDigest: request.action.actionDigest,
|
||||
previewDigest: request.action.previewDigest,
|
||||
risk: request.risk,
|
||||
requestedByType: request.requestedBy.type,
|
||||
requestedById: request.requestedBy.id,
|
||||
requestedAtMs: request.requestedAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
decisionId: request.decisionId,
|
||||
decision: request.decision,
|
||||
decisionReasonCode: request.decisionReasonCode,
|
||||
decidedByType: request.decidedBy?.type ?? null,
|
||||
decidedById: request.decidedBy?.id ?? null,
|
||||
decidedAtMs: request.decidedAtMs,
|
||||
consumptionId: request.consumptionId,
|
||||
dispatchId: request.dispatchId,
|
||||
consumedByType: request.consumedBy?.type ?? null,
|
||||
consumedById: request.consumedBy?.id ?? null,
|
||||
consumedAtMs: request.consumedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToDispatch(
|
||||
row: ApprovedActionDispatchRow,
|
||||
): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
return normalizeApprovedActionDispatchRecord({
|
||||
id: row.id,
|
||||
approvalRequestId: row.approvalRequestId,
|
||||
approvalRequestVersion: Number(row.approvalRequestVersion),
|
||||
projectId: row.projectId,
|
||||
state: row.state as ApprovedActionDispatchRecord['state'],
|
||||
action: {
|
||||
permission:
|
||||
row.permission as ApprovedActionDispatchRecord['action']['permission'],
|
||||
actionType: row.actionType,
|
||||
actionRef: row.actionRef,
|
||||
actionDigest: row.actionDigest,
|
||||
previewDigest: row.previewDigest,
|
||||
},
|
||||
requestedBy: {
|
||||
type: row.requestedByType as ApprovedActionDispatchRecord['requestedBy']['type'],
|
||||
id: row.requestedById,
|
||||
},
|
||||
consumedBy: {
|
||||
type: row.consumedByType as ApprovedActionDispatchRecord['consumedBy']['type'],
|
||||
id: row.consumedById,
|
||||
},
|
||||
createdAtMs: Number(row.createdAtMs),
|
||||
});
|
||||
} catch {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchToRow(
|
||||
dispatch: Readonly<ApprovedActionDispatchRecord>,
|
||||
): ApprovedActionDispatchRow {
|
||||
return {
|
||||
id: dispatch.id,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
approvalRequestVersion: dispatch.approvalRequestVersion,
|
||||
projectId: dispatch.projectId,
|
||||
state: dispatch.state,
|
||||
permission: dispatch.action.permission,
|
||||
actionType: dispatch.action.actionType,
|
||||
actionRef: dispatch.action.actionRef,
|
||||
actionDigest: dispatch.action.actionDigest,
|
||||
previewDigest: dispatch.action.previewDigest,
|
||||
requestedByType: dispatch.requestedBy.type,
|
||||
requestedById: dispatch.requestedBy.id,
|
||||
consumedByType: dispatch.consumedBy.type,
|
||||
consumedById: dispatch.consumedBy.id,
|
||||
createdAtMs: dispatch.createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sameRequestCreation(
|
||||
left: Readonly<ApprovalRequestRecord>,
|
||||
right: Readonly<ApprovalRequestRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id &&
|
||||
left.projectId === right.projectId &&
|
||||
sameApprovalAction(left.action, right.action) &&
|
||||
left.risk === right.risk &&
|
||||
sameApprovalSubject(left.requestedBy, right.requestedBy) &&
|
||||
left.requestedAtMs === right.requestedAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function sameDispatch(
|
||||
left: Readonly<ApprovedActionDispatchRecord>,
|
||||
right: Readonly<ApprovedActionDispatchRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id &&
|
||||
left.approvalRequestId === right.approvalRequestId &&
|
||||
left.approvalRequestVersion === right.approvalRequestVersion &&
|
||||
left.projectId === right.projectId &&
|
||||
left.state === right.state &&
|
||||
sameApprovalAction(left.action, right.action) &&
|
||||
sameApprovalSubject(left.requestedBy, right.requestedBy) &&
|
||||
sameApprovalSubject(left.consumedBy, right.consumedBy) &&
|
||||
left.createdAtMs === right.createdAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
for (const candidate of [
|
||||
error,
|
||||
'original' in error ? error.original : undefined,
|
||||
'parent' in error ? error.parent : undefined,
|
||||
]) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === 'object' &&
|
||||
'code' in candidate &&
|
||||
typeof candidate.code === 'string'
|
||||
) {
|
||||
return candidate.code;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function retryDelay(attempt: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 2 ** attempt));
|
||||
}
|
||||
|
||||
function isApprovalError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof InvalidApprovalValueError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalRequestNotFoundError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestVersionConflictError ||
|
||||
error instanceof ApprovalUnavailableError
|
||||
);
|
||||
}
|
||||
|
||||
export class LegacySequelizeApprovalRequestRepository
|
||||
implements ApprovalRequestRepository
|
||||
{
|
||||
private readonly requests: ModelStatic<ApprovalRequestInstance>;
|
||||
private readonly dispatches: ModelStatic<ApprovedActionDispatchInstance>;
|
||||
|
||||
constructor(private readonly database: Sequelize) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Approval request repository is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
this.requests = defineApprovalRequestModel(database);
|
||||
this.dispatches = defineApprovedActionDispatchModel(database);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Readonly<ApprovalRequestRecord> | null> {
|
||||
const row = await this.requests.findByPk(id, { raw: true });
|
||||
return row ? rowToRequest(row) : null;
|
||||
}
|
||||
|
||||
private async assertFence(
|
||||
projectId: string,
|
||||
subject: Readonly<ApprovalRequestRecord['requestedBy']>,
|
||||
requestedFence: Readonly<ProjectPolicyFence>,
|
||||
transaction: Transaction,
|
||||
): Promise<void> {
|
||||
const normalizedSubject = normalizePolicySubject(subject);
|
||||
const fence = normalizeApprovalPolicyFence(requestedFence);
|
||||
const rows = await this.database.query<PolicyFenceRow>(
|
||||
`SELECT project.version AS project_version,
|
||||
(SELECT MAX(binding.version)
|
||||
FROM "${PROJECT_ROLE_BINDING_TABLE}" AS binding
|
||||
WHERE binding.project_id = project.id
|
||||
AND binding.subject_type = :subjectType
|
||||
AND binding.subject_id = :subjectId) AS binding_version
|
||||
FROM "${PROJECT_TABLE}" AS project
|
||||
WHERE project.id = :projectId
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
projectId,
|
||||
subjectType: normalizedSubject.type,
|
||||
subjectId: normalizedSubject.id,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (rows.length !== 1) throw new ApprovalPolicyFenceConflictError();
|
||||
const currentProjectVersion = Number(rows[0].project_version);
|
||||
const currentBindingVersion =
|
||||
rows[0].binding_version === null ? null : Number(rows[0].binding_version);
|
||||
if (
|
||||
currentProjectVersion !== fence.projectVersion ||
|
||||
currentBindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new ApprovalPolicyFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
private async findDecisionReplay(
|
||||
decisionId: string,
|
||||
transaction: Transaction,
|
||||
): Promise<Readonly<ApprovalRequestRecord> | null> {
|
||||
const row = await this.requests.findOne({
|
||||
where: { decisionId },
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
return row ? rowToRequest(row) : null;
|
||||
}
|
||||
|
||||
private async findConsumptionReplay(
|
||||
consumptionId: string,
|
||||
transaction: Transaction,
|
||||
): Promise<{
|
||||
request: Readonly<ApprovalRequestRecord>;
|
||||
dispatch: Readonly<ApprovedActionDispatchRecord>;
|
||||
} | null> {
|
||||
const row = await this.requests.findOne({
|
||||
where: { consumptionId },
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
if (!row) return null;
|
||||
const request = rowToRequest(row);
|
||||
if (!request.dispatchId) throw new ApprovalUnavailableError();
|
||||
const dispatchRow = await this.dispatches.findByPk(request.dispatchId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
if (!dispatchRow) throw new ApprovalUnavailableError();
|
||||
const executionRows = await this.database.query<{
|
||||
dispatch_id: string;
|
||||
project_id: string;
|
||||
}>(
|
||||
`SELECT dispatch_id, project_id
|
||||
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
||||
WHERE dispatch_id = :dispatchId
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { dispatchId: request.dispatchId },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (
|
||||
executionRows.length !== 1 ||
|
||||
executionRows[0].project_id !== request.projectId
|
||||
) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return { request, dispatch: rowToDispatch(dispatchRow) };
|
||||
}
|
||||
|
||||
async create(
|
||||
command: CreateApprovalRequestCommand,
|
||||
): Promise<CreateApprovalRequestResult> {
|
||||
const request = normalizeApprovalRequestRecord(command.request);
|
||||
const fence = normalizeApprovalPolicyFence(command.authorizationFence);
|
||||
if (request.state !== 'pending' || request.version !== 1) {
|
||||
throw new ApprovalRequestStateConflictError();
|
||||
}
|
||||
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await this.database.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const existing = await this.requests.findByPk(request.id, {
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
if (existing) {
|
||||
const previous = rowToRequest(existing);
|
||||
if (!sameRequestCreation(previous, request)) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return { status: 'existing', request: previous };
|
||||
}
|
||||
await this.assertFence(
|
||||
request.projectId,
|
||||
request.requestedBy,
|
||||
fence,
|
||||
transaction,
|
||||
);
|
||||
await this.requests.create(requestToRow(request), { transaction });
|
||||
return { status: 'created', request };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isApprovalError(error)) throw error;
|
||||
if (
|
||||
(error instanceof UniqueConstraintError ||
|
||||
errorCode(error) === 'SQLITE_BUSY') &&
|
||||
attempt < RETRY_ATTEMPTS - 1
|
||||
) {
|
||||
await retryDelay(attempt);
|
||||
continue;
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
|
||||
async decide(
|
||||
command: DecideApprovalRequestCommand,
|
||||
): Promise<DecideApprovalRequestResult> {
|
||||
const decidedBy = normalizePolicySubject(command.decidedBy);
|
||||
const fence = normalizeApprovalPolicyFence(command.authorizationFence);
|
||||
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await this.database.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const replay = await this.findDecisionReplay(
|
||||
command.decisionId,
|
||||
transaction,
|
||||
);
|
||||
if (replay) {
|
||||
if (
|
||||
replay.id !== command.requestId ||
|
||||
command.expectedVersion !== 1 ||
|
||||
replay.decisionId !== command.decisionId ||
|
||||
replay.decision !== command.decision ||
|
||||
replay.decisionReasonCode !== command.reasonCode ||
|
||||
!replay.decidedBy ||
|
||||
!sameApprovalSubject(replay.decidedBy, decidedBy) ||
|
||||
replay.decidedAtMs !== command.decidedAtMs
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return { status: 'existing', request: replay };
|
||||
}
|
||||
const row = await this.requests.findByPk(command.requestId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
if (!row) throw new ApprovalRequestNotFoundError();
|
||||
const current = rowToRequest(row);
|
||||
if (command.decidedAtMs >= current.expiresAtMs) {
|
||||
throw new ApprovalRequestExpiredError();
|
||||
}
|
||||
if (current.version !== command.expectedVersion) {
|
||||
throw new ApprovalRequestVersionConflictError();
|
||||
}
|
||||
if (current.state !== 'pending') {
|
||||
throw new ApprovalRequestStateConflictError();
|
||||
}
|
||||
await this.assertFence(
|
||||
current.projectId,
|
||||
decidedBy,
|
||||
fence,
|
||||
transaction,
|
||||
);
|
||||
const decided = normalizeApprovalRequestRecord({
|
||||
...current,
|
||||
version: 2,
|
||||
state: command.decision,
|
||||
decisionId: command.decisionId,
|
||||
decision: command.decision,
|
||||
decisionReasonCode: command.reasonCode,
|
||||
decidedBy,
|
||||
decidedAtMs: command.decidedAtMs,
|
||||
});
|
||||
const [updated] = await this.requests.update(
|
||||
requestToRow(decided),
|
||||
{
|
||||
where: {
|
||||
id: current.id,
|
||||
version: command.expectedVersion,
|
||||
state: 'pending',
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (updated !== 1) {
|
||||
throw new ApprovalRequestVersionConflictError();
|
||||
}
|
||||
return { status: 'decided', request: decided };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isApprovalError(error)) throw error;
|
||||
if (
|
||||
(error instanceof UniqueConstraintError ||
|
||||
errorCode(error) === 'SQLITE_BUSY') &&
|
||||
attempt < RETRY_ATTEMPTS - 1
|
||||
) {
|
||||
await retryDelay(attempt);
|
||||
continue;
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
|
||||
async consume(
|
||||
command: ConsumeApprovalRequestCommand,
|
||||
): Promise<ConsumeApprovalRequestResult> {
|
||||
const action = normalizeApprovalActionBinding(command.action);
|
||||
const requestedBy = normalizePolicySubject(command.requestedBy);
|
||||
const consumedBy = normalizePolicySubject(command.consumedBy);
|
||||
const fence = normalizeApprovalPolicyFence(command.authorizationFence);
|
||||
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await this.database.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const replay = await this.findConsumptionReplay(
|
||||
command.consumptionId,
|
||||
transaction,
|
||||
);
|
||||
if (replay) {
|
||||
const expectedDispatch = normalizeApprovedActionDispatchRecord({
|
||||
id: command.dispatchId,
|
||||
approvalRequestId: command.requestId,
|
||||
approvalRequestVersion: 3,
|
||||
projectId: replay.request.projectId,
|
||||
state: 'pending',
|
||||
action,
|
||||
requestedBy,
|
||||
consumedBy,
|
||||
createdAtMs: command.consumedAtMs,
|
||||
});
|
||||
if (
|
||||
command.expectedVersion !== 2 ||
|
||||
replay.request.id !== command.requestId ||
|
||||
replay.request.consumptionId !== command.consumptionId ||
|
||||
!sameDispatch(replay.dispatch, expectedDispatch)
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return {
|
||||
status: 'existing',
|
||||
request: replay.request,
|
||||
dispatch: replay.dispatch,
|
||||
};
|
||||
}
|
||||
const row = await this.requests.findByPk(command.requestId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
});
|
||||
if (!row) throw new ApprovalRequestNotFoundError();
|
||||
const current = rowToRequest(row);
|
||||
if (command.consumedAtMs >= current.expiresAtMs) {
|
||||
throw new ApprovalRequestExpiredError();
|
||||
}
|
||||
if (current.version !== command.expectedVersion) {
|
||||
throw new ApprovalRequestVersionConflictError();
|
||||
}
|
||||
if (current.state !== 'approved') {
|
||||
throw new ApprovalRequestStateConflictError();
|
||||
}
|
||||
if (
|
||||
!sameApprovalAction(current.action, action) ||
|
||||
!sameApprovalSubject(current.requestedBy, requestedBy)
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
await this.assertFence(
|
||||
current.projectId,
|
||||
requestedBy,
|
||||
fence,
|
||||
transaction,
|
||||
);
|
||||
const dispatch = normalizeApprovedActionDispatchRecord({
|
||||
id: command.dispatchId,
|
||||
approvalRequestId: current.id,
|
||||
approvalRequestVersion: 3,
|
||||
projectId: current.projectId,
|
||||
state: 'pending',
|
||||
action,
|
||||
requestedBy,
|
||||
consumedBy,
|
||||
createdAtMs: command.consumedAtMs,
|
||||
});
|
||||
const dispatchCollision = await this.dispatches.findByPk(
|
||||
dispatch.id,
|
||||
{ raw: true, transaction },
|
||||
);
|
||||
if (dispatchCollision) throw new ApprovalMutationConflictError();
|
||||
await this.dispatches.create(dispatchToRow(dispatch), {
|
||||
transaction,
|
||||
});
|
||||
await this.database.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,
|
||||
last_result_code, completed_at_ms, created_at_ms, updated_at_ms)
|
||||
VALUES
|
||||
(:dispatchId, :projectId, 'pending', 0, 0, :maxAttempts,
|
||||
:createdAtMs, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
:createdAtMs, :createdAtMs)`,
|
||||
{
|
||||
replacements: {
|
||||
dispatchId: dispatch.id,
|
||||
projectId: dispatch.projectId,
|
||||
maxAttempts: DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS,
|
||||
createdAtMs: dispatch.createdAtMs,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
const consumed = normalizeApprovalRequestRecord({
|
||||
...current,
|
||||
version: 3,
|
||||
state: 'consumed',
|
||||
consumptionId: command.consumptionId,
|
||||
dispatchId: command.dispatchId,
|
||||
consumedBy,
|
||||
consumedAtMs: command.consumedAtMs,
|
||||
});
|
||||
const [updated] = await this.requests.update(
|
||||
requestToRow(consumed),
|
||||
{
|
||||
where: {
|
||||
id: current.id,
|
||||
version: command.expectedVersion,
|
||||
state: 'approved',
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (updated !== 1) {
|
||||
throw new ApprovalRequestVersionConflictError();
|
||||
}
|
||||
return { status: 'consumed', request: consumed, dispatch };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isApprovalError(error)) throw error;
|
||||
if (
|
||||
(error instanceof UniqueConstraintError ||
|
||||
errorCode(error) === 'SQLITE_BUSY') &&
|
||||
attempt < RETRY_ATTEMPTS - 1
|
||||
) {
|
||||
await retryDelay(attempt);
|
||||
continue;
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
QueryTypes,
|
||||
Sequelize,
|
||||
Transaction,
|
||||
type Transaction as SequelizeTransaction,
|
||||
} from 'sequelize';
|
||||
import { APPROVED_RUN_ACTION_RECEIPT_TABLE } from '../../../migrations/0023-approved-run-action-receipts';
|
||||
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from '../../../migrations/0021-approved-action-dispatch-executions';
|
||||
import {
|
||||
APPROVED_RUN_ACTION_TYPE,
|
||||
APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
ApprovedRunActionBindingConflictError,
|
||||
ApprovedRunActionRepositoryError,
|
||||
InvalidApprovedRunActionError,
|
||||
digestApprovedRunCreationPlan,
|
||||
digestApprovedRunCreationReceipt,
|
||||
normalizeApprovedRunCreationPlan,
|
||||
normalizeApprovedRunCreationReceipt,
|
||||
type ApprovedRunCreationReceipt,
|
||||
} from '../../domain/approvedRunAction';
|
||||
import {
|
||||
normalizeApprovedActionDispatchExecutionRecord,
|
||||
type ApprovedActionDispatchExecutionSnapshot,
|
||||
} from '../../domain/approvedActionDispatchExecution';
|
||||
import { normalizeApprovedActionDispatchRecord } from '../../domain/approvalRequest';
|
||||
import { DuplicateIdempotencyKeyError } from '../../domain/repositoryErrors';
|
||||
import type { RunRecord } from '../../domain/run';
|
||||
import {
|
||||
PrimaryRunCreator,
|
||||
type PrimaryRunIdFactory,
|
||||
} from '../../application/primaryRunCreator';
|
||||
import type {
|
||||
ApprovedRunActionRepository,
|
||||
ApprovedRunReference,
|
||||
CreateApprovedRunCommand,
|
||||
} from '../../ports/approvedRunActionRepository';
|
||||
import type { RunRepositoryTransaction } from '../../ports/runRepository';
|
||||
import {
|
||||
LegacySequelizeRunRepository,
|
||||
LegacySequelizeRunTransaction,
|
||||
} from './runRepository';
|
||||
|
||||
interface ApprovedRunReceiptRow {
|
||||
schema_version: number;
|
||||
dispatch_id: string;
|
||||
approval_request_id: string;
|
||||
project_id: string;
|
||||
action_type: string;
|
||||
action_digest: string;
|
||||
execution_attempt: number;
|
||||
execution_version: number;
|
||||
started_at_ms: number;
|
||||
idempotency_key: string;
|
||||
outcome: string;
|
||||
result_code: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
finished_at_ms: number;
|
||||
evidence_digest: string;
|
||||
created_at_ms: number;
|
||||
}
|
||||
|
||||
interface ReceiptBinding {
|
||||
snapshot: Readonly<ApprovedActionDispatchExecutionSnapshot>;
|
||||
clock: () => number;
|
||||
}
|
||||
|
||||
interface ExecutionFenceRow {
|
||||
project_id: string;
|
||||
status: string;
|
||||
version: number;
|
||||
attempt_count: number;
|
||||
lease_owner: string | null;
|
||||
lease_token: string | null;
|
||||
started_at_ms: number | null;
|
||||
}
|
||||
|
||||
function rowToReceipt(
|
||||
row: ApprovedRunReceiptRow,
|
||||
): Readonly<ApprovedRunCreationReceipt> {
|
||||
return normalizeApprovedRunCreationReceipt({
|
||||
schemaVersion: row.schema_version as 1,
|
||||
dispatchId: row.dispatch_id,
|
||||
approvalRequestId: row.approval_request_id,
|
||||
projectId: row.project_id,
|
||||
actionType: row.action_type as typeof APPROVED_RUN_ACTION_TYPE,
|
||||
actionDigest: row.action_digest,
|
||||
executionAttempt: row.execution_attempt,
|
||||
executionVersion: row.execution_version,
|
||||
startedAtMs: row.started_at_ms,
|
||||
idempotencyKey: row.idempotency_key,
|
||||
outcome: row.outcome as 'succeeded',
|
||||
resultCode: row.result_code as typeof APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
resourceType: row.resource_type as 'run',
|
||||
resourceId: row.resource_id,
|
||||
finishedAtMs: row.finished_at_ms,
|
||||
evidenceDigest: row.evidence_digest,
|
||||
createdAtMs: row.created_at_ms,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: Readonly<ApprovedActionDispatchExecutionSnapshot>,
|
||||
): Readonly<ApprovedActionDispatchExecutionSnapshot> {
|
||||
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
throw new InvalidApprovedRunActionError('execution snapshot is invalid');
|
||||
}
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(snapshot.dispatch);
|
||||
const execution = normalizeApprovedActionDispatchExecutionRecord(
|
||||
snapshot.execution,
|
||||
);
|
||||
if (
|
||||
execution.dispatchId !== dispatch.id ||
|
||||
execution.projectId !== dispatch.projectId ||
|
||||
execution.status !== 'executing' ||
|
||||
execution.startedAtMs === null
|
||||
) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
return Object.freeze({ dispatch, execution });
|
||||
}
|
||||
|
||||
function receiptMatches(
|
||||
receipt: Readonly<ApprovedRunCreationReceipt>,
|
||||
snapshot: Readonly<ApprovedActionDispatchExecutionSnapshot>,
|
||||
): boolean {
|
||||
return (
|
||||
receipt.dispatchId === snapshot.dispatch.id &&
|
||||
receipt.approvalRequestId === snapshot.dispatch.approvalRequestId &&
|
||||
receipt.projectId === snapshot.dispatch.projectId &&
|
||||
receipt.actionType === snapshot.dispatch.action.actionType &&
|
||||
receipt.actionDigest === snapshot.dispatch.action.actionDigest &&
|
||||
receipt.executionAttempt === snapshot.execution.attemptCount &&
|
||||
receipt.startedAtMs === snapshot.execution.startedAtMs &&
|
||||
receipt.idempotencyKey === snapshot.dispatch.id
|
||||
);
|
||||
}
|
||||
|
||||
class AtomicApprovedRunRepository extends LegacySequelizeRunRepository {
|
||||
constructor(
|
||||
private readonly approvedDatabase: Sequelize,
|
||||
private readonly binding: Readonly<ReceiptBinding>,
|
||||
) {
|
||||
super(approvedDatabase);
|
||||
}
|
||||
|
||||
override async transaction<T>(
|
||||
work: (transaction: RunRepositoryTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.approvedDatabase.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const executionVersion = await this.requireCurrentExecutionFence(
|
||||
transaction,
|
||||
);
|
||||
const result = await work(
|
||||
new LegacySequelizeRunTransaction(this.models, transaction),
|
||||
);
|
||||
const run = this.requireCreatedRun(result);
|
||||
await this.insertReceipt(run, executionVersion, transaction);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async requireCurrentExecutionFence(
|
||||
transaction: SequelizeTransaction,
|
||||
): Promise<number> {
|
||||
const { dispatch, execution } = this.binding.snapshot;
|
||||
const rows = await this.approvedDatabase.query<ExecutionFenceRow>(
|
||||
`SELECT project_id, status, version, attempt_count, lease_owner,
|
||||
lease_token, started_at_ms
|
||||
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
||||
WHERE dispatch_id = :dispatchId
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { dispatchId: dispatch.id },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
const current = rows[0];
|
||||
if (
|
||||
rows.length !== 1 ||
|
||||
current.project_id !== dispatch.projectId ||
|
||||
current.status !== 'executing' ||
|
||||
current.version < execution.version ||
|
||||
current.attempt_count !== execution.attemptCount ||
|
||||
current.lease_owner !== execution.leaseOwner ||
|
||||
current.lease_token !== execution.leaseToken ||
|
||||
current.started_at_ms !== execution.startedAtMs
|
||||
) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
return current.version;
|
||||
}
|
||||
|
||||
private requireCreatedRun(value: unknown): Readonly<RunRecord> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!('id' in value) ||
|
||||
typeof value.id !== 'string' ||
|
||||
!('projectId' in value) ||
|
||||
value.projectId !== this.binding.snapshot.dispatch.projectId ||
|
||||
!('idempotencyKey' in value) ||
|
||||
value.idempotencyKey !== this.binding.snapshot.dispatch.id ||
|
||||
!('requestId' in value) ||
|
||||
value.requestId !== this.binding.snapshot.dispatch.approvalRequestId ||
|
||||
!('status' in value) ||
|
||||
value.status !== 'queued'
|
||||
) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
return value as Readonly<RunRecord>;
|
||||
}
|
||||
|
||||
private async insertReceipt(
|
||||
run: Readonly<RunRecord>,
|
||||
executionVersion: number,
|
||||
transaction: SequelizeTransaction,
|
||||
): Promise<void> {
|
||||
const { dispatch, execution } = this.binding.snapshot;
|
||||
const finishedAtMs = this.nowAtOrAfter(execution.startedAtMs!);
|
||||
const unsigned: Omit<ApprovedRunCreationReceipt, 'evidenceDigest'> = {
|
||||
schemaVersion: 1,
|
||||
dispatchId: dispatch.id,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
projectId: dispatch.projectId,
|
||||
actionType: APPROVED_RUN_ACTION_TYPE,
|
||||
actionDigest: dispatch.action.actionDigest,
|
||||
executionAttempt: execution.attemptCount,
|
||||
executionVersion,
|
||||
startedAtMs: execution.startedAtMs!,
|
||||
idempotencyKey: dispatch.id,
|
||||
outcome: 'succeeded',
|
||||
resultCode: APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
resourceType: 'run',
|
||||
resourceId: run.id,
|
||||
finishedAtMs,
|
||||
createdAtMs: finishedAtMs,
|
||||
};
|
||||
const receipt = normalizeApprovedRunCreationReceipt({
|
||||
...unsigned,
|
||||
evidenceDigest: digestApprovedRunCreationReceipt(unsigned),
|
||||
});
|
||||
await this.approvedDatabase.query(
|
||||
`INSERT INTO "${APPROVED_RUN_ACTION_RECEIPT_TABLE}"
|
||||
(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)
|
||||
VALUES
|
||||
(:dispatchId, :approvalRequestId, :projectId, :schemaVersion,
|
||||
:actionType, :actionDigest, :executionAttempt, :executionVersion,
|
||||
:startedAtMs, :idempotencyKey, :outcome, :resultCode, :resourceType,
|
||||
:resourceId, :finishedAtMs, :evidenceDigest, :createdAtMs)`,
|
||||
{ replacements: receipt, transaction },
|
||||
);
|
||||
}
|
||||
|
||||
private nowAtOrAfter(minimum: number): number {
|
||||
const nowMs = this.binding.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < minimum) {
|
||||
throw new RangeError('clock must not precede the action start barrier');
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacySequelizeApprovedRunActionRepositoryOptions {
|
||||
clock?: () => number;
|
||||
createId?: PrimaryRunIdFactory;
|
||||
}
|
||||
|
||||
export class LegacySequelizeApprovedRunActionRepository
|
||||
implements ApprovedRunActionRepository
|
||||
{
|
||||
private readonly runs: LegacySequelizeRunRepository;
|
||||
private readonly clock: () => number;
|
||||
private readonly createId?: PrimaryRunIdFactory;
|
||||
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
options: LegacySequelizeApprovedRunActionRepositoryOptions = {},
|
||||
) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Approved Run action repository is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
this.runs = new LegacySequelizeRunRepository(database);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.createId = options.createId;
|
||||
}
|
||||
|
||||
async create(
|
||||
command: Readonly<CreateApprovedRunCommand>,
|
||||
): Promise<Readonly<ApprovedRunReference>> {
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(command.snapshot);
|
||||
const plan = normalizeApprovedRunCreationPlan(command.plan);
|
||||
this.assertPlanBinding(snapshot, plan);
|
||||
const replay = await this.findReplay(snapshot);
|
||||
if (replay) return replay;
|
||||
|
||||
const atomic = new AtomicApprovedRunRepository(this.database, {
|
||||
snapshot,
|
||||
clock: this.clock,
|
||||
});
|
||||
const creator = new PrimaryRunCreator(atomic, this.createId);
|
||||
try {
|
||||
return await creator.create(
|
||||
{
|
||||
projectId: plan.projectId,
|
||||
taskId: plan.taskId,
|
||||
taskRevision: plan.taskRevision,
|
||||
...(plan.taskName === undefined ? {} : { taskName: plan.taskName }),
|
||||
...(plan.taskSnapshotRef === undefined
|
||||
? {}
|
||||
: { taskSnapshotRef: plan.taskSnapshotRef }),
|
||||
triggerType: 'approved_action',
|
||||
executionOrigin: 'system',
|
||||
triggeredBy: `approved-action:${snapshot.dispatch.id}`,
|
||||
requestId: snapshot.dispatch.approvalRequestId,
|
||||
priority: plan.priority,
|
||||
idempotencyKey: snapshot.dispatch.id,
|
||||
...(plan.inputRef === undefined ? {} : { inputRef: plan.inputRef }),
|
||||
acceptedAtMs: snapshot.execution.startedAtMs!,
|
||||
actor: { type: 'system', id: 'approved-action-dispatcher' },
|
||||
},
|
||||
plan.executorType,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DuplicateIdempotencyKeyError)) throw error;
|
||||
const raced = await this.findReplay(snapshot);
|
||||
if (raced) return raced;
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApprovedRunActionBindingConflictError ||
|
||||
error instanceof InvalidApprovedRunActionError ||
|
||||
error instanceof RangeError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApprovedRunActionRepositoryError();
|
||||
}
|
||||
}
|
||||
|
||||
private assertPlanBinding(
|
||||
snapshot: Readonly<ApprovedActionDispatchExecutionSnapshot>,
|
||||
plan: Readonly<ReturnType<typeof normalizeApprovedRunCreationPlan>>,
|
||||
): void {
|
||||
if (
|
||||
snapshot.dispatch.action.actionType !== APPROVED_RUN_ACTION_TYPE ||
|
||||
snapshot.dispatch.action.actionRef !== plan.actionRef ||
|
||||
snapshot.dispatch.projectId !== plan.projectId ||
|
||||
snapshot.dispatch.action.actionDigest !==
|
||||
digestApprovedRunCreationPlan(plan)
|
||||
) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
private async findReplay(
|
||||
snapshot: Readonly<ApprovedActionDispatchExecutionSnapshot>,
|
||||
): Promise<Readonly<ApprovedRunReference> | null> {
|
||||
const rows = await this.database.query<ApprovedRunReceiptRow>(
|
||||
`SELECT schema_version, dispatch_id, approval_request_id, project_id,
|
||||
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
|
||||
FROM "${APPROVED_RUN_ACTION_RECEIPT_TABLE}"
|
||||
WHERE dispatch_id = :dispatchId
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { dispatchId: snapshot.dispatch.id },
|
||||
},
|
||||
);
|
||||
if (rows.length > 1) throw new ApprovedRunActionBindingConflictError();
|
||||
if (rows.length === 0) {
|
||||
const collisions = await this.database.query<{ id: string }>(
|
||||
`SELECT id FROM "Runs"
|
||||
WHERE project_id = :projectId AND idempotency_key = :idempotencyKey
|
||||
LIMIT 1`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
projectId: snapshot.dispatch.projectId,
|
||||
idempotencyKey: snapshot.dispatch.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (collisions.length > 0) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const receipt = rowToReceipt(rows[0]);
|
||||
if (!receiptMatches(receipt, snapshot)) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
const run = await this.runs.findRunById(receipt.resourceId);
|
||||
const attempt = await this.runs.findLatestAttemptByRunId(
|
||||
receipt.resourceId,
|
||||
);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
run.projectId !== receipt.projectId ||
|
||||
run.idempotencyKey !== receipt.idempotencyKey ||
|
||||
run.requestId !== receipt.approvalRequestId ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.executionOrigin !== 'system' ||
|
||||
run.triggerType !== 'approved_action'
|
||||
) {
|
||||
throw new ApprovedRunActionBindingConflictError();
|
||||
}
|
||||
return Object.freeze({ run, attempt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
import { APPROVED_RUN_ACTION_RECEIPT_TABLE } from '../../../migrations/0023-approved-run-action-receipts';
|
||||
import {
|
||||
APPROVED_RUN_ACTION_TYPE,
|
||||
APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
InvalidApprovedRunActionError,
|
||||
normalizeApprovedRunCreationReceipt,
|
||||
type ApprovedRunCreationReceipt,
|
||||
} from '../../domain/approvedRunAction';
|
||||
import type {
|
||||
ApprovedActionRecoveryEvidence,
|
||||
ApprovedActionRecoveryEvidenceContext,
|
||||
ApprovedActionRecoveryEvidenceProvider,
|
||||
} from '../../ports/approvedActionRecoveryEvidenceProvider';
|
||||
import { LegacySequelizeRunRepository } from './runRepository';
|
||||
|
||||
interface ReceiptRow {
|
||||
schema_version: number;
|
||||
dispatch_id: string;
|
||||
approval_request_id: string;
|
||||
project_id: string;
|
||||
action_type: string;
|
||||
action_digest: string;
|
||||
execution_attempt: number;
|
||||
execution_version: number;
|
||||
started_at_ms: number;
|
||||
idempotency_key: string;
|
||||
outcome: string;
|
||||
result_code: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
finished_at_ms: number;
|
||||
evidence_digest: string;
|
||||
created_at_ms: number;
|
||||
}
|
||||
|
||||
function normalizeRow(row: ReceiptRow): Readonly<ApprovedRunCreationReceipt> {
|
||||
return normalizeApprovedRunCreationReceipt({
|
||||
schemaVersion: row.schema_version as 1,
|
||||
dispatchId: row.dispatch_id,
|
||||
approvalRequestId: row.approval_request_id,
|
||||
projectId: row.project_id,
|
||||
actionType: row.action_type as typeof APPROVED_RUN_ACTION_TYPE,
|
||||
actionDigest: row.action_digest,
|
||||
executionAttempt: row.execution_attempt,
|
||||
executionVersion: row.execution_version,
|
||||
startedAtMs: row.started_at_ms,
|
||||
idempotencyKey: row.idempotency_key,
|
||||
outcome: row.outcome as 'succeeded',
|
||||
resultCode: row.result_code as typeof APPROVED_RUN_RECEIPT_RESULT_CODE,
|
||||
resourceType: row.resource_type as 'run',
|
||||
resourceId: row.resource_id,
|
||||
finishedAtMs: row.finished_at_ms,
|
||||
evidenceDigest: row.evidence_digest,
|
||||
createdAtMs: row.created_at_ms,
|
||||
});
|
||||
}
|
||||
|
||||
const CONFLICT: ApprovedActionRecoveryEvidence = Object.freeze({
|
||||
finding: 'conflict',
|
||||
resultCode: 'approved_run_receipt_conflict',
|
||||
});
|
||||
|
||||
export class LegacySequelizeApprovedRunRecoveryEvidenceProvider
|
||||
implements ApprovedActionRecoveryEvidenceProvider
|
||||
{
|
||||
readonly actionType = APPROVED_RUN_ACTION_TYPE;
|
||||
readonly capability = 'automatic' as const;
|
||||
private readonly runs: LegacySequelizeRunRepository;
|
||||
|
||||
constructor(private readonly database: Sequelize) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Approved Run recovery provider is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
this.runs = new LegacySequelizeRunRepository(database);
|
||||
}
|
||||
|
||||
async inspect(
|
||||
context: Readonly<ApprovedActionRecoveryEvidenceContext>,
|
||||
): Promise<ApprovedActionRecoveryEvidence> {
|
||||
const snapshot = context.snapshot;
|
||||
const dispatch = snapshot.action.dispatch;
|
||||
const execution = snapshot.action.execution;
|
||||
if (
|
||||
dispatch.action.actionType !== this.actionType ||
|
||||
context.idempotencyKey !== dispatch.id ||
|
||||
execution.dispatchId !== dispatch.id ||
|
||||
execution.projectId !== dispatch.projectId ||
|
||||
execution.startedAtMs === null
|
||||
) {
|
||||
return CONFLICT;
|
||||
}
|
||||
const rows = await this.database.query<ReceiptRow>(
|
||||
`SELECT schema_version, dispatch_id, approval_request_id, project_id,
|
||||
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
|
||||
FROM "${APPROVED_RUN_ACTION_RECEIPT_TABLE}"
|
||||
WHERE dispatch_id = :dispatchId
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { dispatchId: dispatch.id },
|
||||
},
|
||||
);
|
||||
if (rows.length > 1) return CONFLICT;
|
||||
if (rows.length === 0) {
|
||||
const collisions = await this.database.query<{ id: string }>(
|
||||
`SELECT id FROM "Runs"
|
||||
WHERE project_id = :projectId AND idempotency_key = :idempotencyKey
|
||||
LIMIT 1`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
projectId: dispatch.projectId,
|
||||
idempotencyKey: dispatch.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
return collisions.length === 0
|
||||
? {
|
||||
finding: 'missing',
|
||||
resultCode: 'approved_run_receipt_missing',
|
||||
}
|
||||
: CONFLICT;
|
||||
}
|
||||
|
||||
let receipt: Readonly<ApprovedRunCreationReceipt>;
|
||||
try {
|
||||
receipt = normalizeRow(rows[0]);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidApprovedRunActionError) return CONFLICT;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
receipt.dispatchId !== dispatch.id ||
|
||||
receipt.approvalRequestId !== dispatch.approvalRequestId ||
|
||||
receipt.projectId !== dispatch.projectId ||
|
||||
receipt.actionType !== dispatch.action.actionType ||
|
||||
receipt.actionDigest !== dispatch.action.actionDigest ||
|
||||
receipt.executionAttempt !== execution.attemptCount ||
|
||||
receipt.executionVersion > execution.version ||
|
||||
receipt.startedAtMs !== execution.startedAtMs ||
|
||||
receipt.idempotencyKey !== context.idempotencyKey
|
||||
) {
|
||||
return CONFLICT;
|
||||
}
|
||||
const run = await this.runs.findRunById(receipt.resourceId);
|
||||
const attempt = await this.runs.findLatestAttemptByRunId(
|
||||
receipt.resourceId,
|
||||
);
|
||||
if (
|
||||
!run ||
|
||||
!attempt ||
|
||||
run.projectId !== receipt.projectId ||
|
||||
run.idempotencyKey !== receipt.idempotencyKey ||
|
||||
run.requestId !== receipt.approvalRequestId ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.executionOrigin !== 'system' ||
|
||||
run.triggerType !== 'approved_action'
|
||||
) {
|
||||
return CONFLICT;
|
||||
}
|
||||
return {
|
||||
finding: 'verified_succeeded',
|
||||
resultCode: 'approved_run_receipt_verified',
|
||||
evidenceDigest: receipt.evidenceDigest,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
ModelStatic,
|
||||
Sequelize,
|
||||
Transaction,
|
||||
UniqueConstraintError,
|
||||
} from 'sequelize';
|
||||
import {
|
||||
RUN_EVENT_TABLE,
|
||||
RUN_TABLE,
|
||||
RUN_ATTEMPT_TABLE,
|
||||
} from '../../../migrations/0002-run-schema';
|
||||
import { RUN_CANCELLATION_DISPATCH_TABLE } from '../../../migrations/0005-run-cancellation-dispatch';
|
||||
import {
|
||||
CANCELLATION_DISPATCH_RESULTS,
|
||||
CANCELLATION_DISPATCH_STATUSES,
|
||||
type CancellationDispatchRecord,
|
||||
type CancellationDispatchResult,
|
||||
type CancellationDispatchStatus,
|
||||
} from '../../domain/cancellationDispatch';
|
||||
import {
|
||||
CancellationDispatchBindingConflictError,
|
||||
CancellationDispatchFenceRejectedError,
|
||||
CancellationDispatchRepositoryError,
|
||||
InvalidCancellationDispatchCommandError,
|
||||
} from '../../domain/cancellationDispatchErrors';
|
||||
import type { RunEventRecord, RunStatus } from '../../domain/run';
|
||||
import type {
|
||||
CancellationDispatchRepository,
|
||||
ClaimCancellationDispatchCommand,
|
||||
ClaimCancellationDispatchResult,
|
||||
RecordCancellationDispatchResult,
|
||||
RecordCancellationDispatchResultCommand,
|
||||
} from '../../ports/cancellationDispatchRepository';
|
||||
|
||||
const ACTIVE_RUN_STATUSES: readonly RunStatus[] = [
|
||||
'created',
|
||||
'queued',
|
||||
'dispatching',
|
||||
'running',
|
||||
'waiting_approval',
|
||||
'retry_wait',
|
||||
'lost',
|
||||
];
|
||||
const ACTIVE_ATTEMPT_STATUSES = ['claimed', 'starting', 'running'] as const;
|
||||
const RETRYABLE_RESULTS: readonly CancellationDispatchResult[] = [
|
||||
'controller_missing',
|
||||
'handle_missing',
|
||||
'dispatch_error',
|
||||
];
|
||||
const BLOCKING_RESULTS: readonly CancellationDispatchResult[] = [
|
||||
'identity_mismatch',
|
||||
'pid_mismatch',
|
||||
'unsupported',
|
||||
'invalid',
|
||||
];
|
||||
const MAX_LEASE_DURATION_MS = 5 * 60_000;
|
||||
const MAX_RETRY_DELAY_MS = 24 * 60 * 60_000;
|
||||
|
||||
export interface LegacySequelizeCancellationDispatchRepositoryOptions {
|
||||
clock?: () => number;
|
||||
}
|
||||
|
||||
function digestLeaseToken(value: string): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong.cancellation-dispatch-lease.v1\0', 'utf8')
|
||||
.update(value, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
interface CancellationDispatchRow {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
status: string;
|
||||
version: number;
|
||||
dispatchCount: number;
|
||||
nextAttemptAtMs: number | null;
|
||||
leaseOwner: string | null;
|
||||
leaseToken: string | null;
|
||||
leaseExpiresAtMs: number | null;
|
||||
lastResult: string | null;
|
||||
lastDispatchedAtMs: number | null;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
interface CancellationDispatchRunRow {
|
||||
id: string;
|
||||
executionOwner: string;
|
||||
status: string;
|
||||
version: number;
|
||||
eventSequence: number;
|
||||
cancelRequestedAtMs: number | null;
|
||||
}
|
||||
|
||||
interface CancellationDispatchAttemptRow {
|
||||
id: string;
|
||||
runId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CancellationDispatchEventRow {
|
||||
id: string;
|
||||
runId: string;
|
||||
sequence: number;
|
||||
type: string;
|
||||
dedupeKey: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
attemptId: string;
|
||||
payload: Readonly<Record<string, unknown>>;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
interface CancellationDispatchInstance
|
||||
extends Model<CancellationDispatchRow, CancellationDispatchRow>,
|
||||
CancellationDispatchRow {}
|
||||
interface CancellationDispatchRunInstance
|
||||
extends Model<CancellationDispatchRunRow, CancellationDispatchRunRow>,
|
||||
CancellationDispatchRunRow {}
|
||||
interface CancellationDispatchAttemptInstance
|
||||
extends Model<CancellationDispatchAttemptRow, CancellationDispatchAttemptRow>,
|
||||
CancellationDispatchAttemptRow {}
|
||||
interface CancellationDispatchEventInstance
|
||||
extends Model<CancellationDispatchEventRow, CancellationDispatchEventRow>,
|
||||
CancellationDispatchEventRow {}
|
||||
|
||||
function defineDispatchModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<CancellationDispatchInstance> {
|
||||
return database.define<CancellationDispatchInstance>(
|
||||
'Ql3CancellationDispatch',
|
||||
{
|
||||
runId: { field: 'run_id', type: DataTypes.STRING(36), primaryKey: true },
|
||||
attemptId: {
|
||||
field: 'attempt_id',
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
},
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
dispatchCount: {
|
||||
field: 'dispatch_count',
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
nextAttemptAtMs: {
|
||||
field: 'next_attempt_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
leaseOwner: {
|
||||
field: 'lease_owner',
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: true,
|
||||
},
|
||||
leaseToken: {
|
||||
field: 'lease_token',
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: true,
|
||||
},
|
||||
leaseExpiresAtMs: {
|
||||
field: 'lease_expires_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
lastResult: {
|
||||
field: 'last_result',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
},
|
||||
lastDispatchedAtMs: {
|
||||
field: 'last_dispatched_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAtMs: {
|
||||
field: 'created_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAtMs: {
|
||||
field: 'updated_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: RUN_CANCELLATION_DISPATCH_TABLE,
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function defineRunModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<CancellationDispatchRunInstance> {
|
||||
return database.define<CancellationDispatchRunInstance>(
|
||||
'Ql3CancellationDispatchRun',
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), primaryKey: true },
|
||||
executionOwner: {
|
||||
field: 'execution_owner',
|
||||
type: DataTypes.STRING(16),
|
||||
allowNull: false,
|
||||
},
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
||||
eventSequence: {
|
||||
field: 'event_sequence',
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
cancelRequestedAtMs: {
|
||||
field: 'cancel_requested_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{ tableName: RUN_TABLE, timestamps: false, freezeTableName: true },
|
||||
);
|
||||
}
|
||||
|
||||
function defineAttemptModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<CancellationDispatchAttemptInstance> {
|
||||
return database.define<CancellationDispatchAttemptInstance>(
|
||||
'Ql3CancellationDispatchAttempt',
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), primaryKey: true },
|
||||
runId: { field: 'run_id', type: DataTypes.STRING(36), allowNull: false },
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
},
|
||||
{ tableName: RUN_ATTEMPT_TABLE, timestamps: false, freezeTableName: true },
|
||||
);
|
||||
}
|
||||
|
||||
function defineEventModel(
|
||||
database: Sequelize,
|
||||
): ModelStatic<CancellationDispatchEventInstance> {
|
||||
return database.define<CancellationDispatchEventInstance>(
|
||||
'Ql3CancellationDispatchEvent',
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), primaryKey: true },
|
||||
runId: { field: 'run_id', type: DataTypes.STRING(36), allowNull: false },
|
||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
||||
type: { type: DataTypes.STRING(128), allowNull: false },
|
||||
dedupeKey: {
|
||||
field: 'dedupe_key',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
actorType: {
|
||||
field: 'actor_type',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
actorId: {
|
||||
field: 'actor_id',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
attemptId: {
|
||||
field: 'attempt_id',
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
},
|
||||
payload: { type: DataTypes.JSON, allowNull: false },
|
||||
createdAtMs: {
|
||||
field: 'created_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ tableName: RUN_EVENT_TABLE, timestamps: false, freezeTableName: true },
|
||||
);
|
||||
}
|
||||
|
||||
function assertId(name: string, value: string, maxLength = 36): void {
|
||||
if (!value || value.length > maxLength) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
`${name} must be between 1 and ${maxLength} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTimestamp(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
`${name} must be a non-negative safe integer`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaim(command: ClaimCancellationDispatchCommand): void {
|
||||
assertId('runId', command.runId);
|
||||
assertId('attemptId', command.attemptId);
|
||||
assertId('owner', command.owner, 128);
|
||||
assertId('leaseToken', command.leaseToken, 128);
|
||||
assertTimestamp('requestedAtMs', command.requestedAtMs);
|
||||
if (
|
||||
!Number.isSafeInteger(command.leaseDurationMs) ||
|
||||
command.leaseDurationMs < 1 ||
|
||||
command.leaseDurationMs > MAX_LEASE_DURATION_MS
|
||||
) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
`leaseDurationMs must be between 1 and ${MAX_LEASE_DURATION_MS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRecordResult(
|
||||
command: RecordCancellationDispatchResultCommand,
|
||||
): void {
|
||||
assertId('runId', command.runId);
|
||||
assertId('attemptId', command.attemptId);
|
||||
assertId('owner', command.owner, 128);
|
||||
assertId('leaseToken', command.leaseToken, 128);
|
||||
assertId('eventId', command.eventId);
|
||||
if (
|
||||
!Number.isSafeInteger(command.expectedVersion) ||
|
||||
command.expectedVersion < 1
|
||||
) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
'expectedVersion must be a positive safe integer',
|
||||
);
|
||||
}
|
||||
if (!CANCELLATION_DISPATCH_RESULTS.includes(command.result)) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
'result is not supported',
|
||||
);
|
||||
}
|
||||
if (RETRYABLE_RESULTS.includes(command.result)) {
|
||||
if (
|
||||
command.retryDelayMs === undefined ||
|
||||
!Number.isSafeInteger(command.retryDelayMs) ||
|
||||
command.retryDelayMs < 1 ||
|
||||
command.retryDelayMs > MAX_RETRY_DELAY_MS
|
||||
) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
`retryable results require retryDelayMs between 1 and ${MAX_RETRY_DELAY_MS}`,
|
||||
);
|
||||
}
|
||||
} else if (command.retryDelayMs !== undefined) {
|
||||
throw new InvalidCancellationDispatchCommandError(
|
||||
'terminal results must not include retryDelayMs',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rowToDispatch(
|
||||
row: CancellationDispatchRow,
|
||||
): CancellationDispatchRecord {
|
||||
if (
|
||||
!CANCELLATION_DISPATCH_STATUSES.includes(
|
||||
row.status as CancellationDispatchStatus,
|
||||
)
|
||||
) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error(`Unsupported cancellation dispatch status: ${row.status}`),
|
||||
);
|
||||
}
|
||||
if (
|
||||
row.lastResult !== null &&
|
||||
!CANCELLATION_DISPATCH_RESULTS.includes(
|
||||
row.lastResult as CancellationDispatchResult,
|
||||
)
|
||||
) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error(`Unsupported cancellation dispatch result: ${row.lastResult}`),
|
||||
);
|
||||
}
|
||||
for (const [name, value] of [
|
||||
['version', row.version],
|
||||
['dispatchCount', row.dispatchCount],
|
||||
['createdAtMs', row.createdAtMs],
|
||||
['updatedAtMs', row.updatedAtMs],
|
||||
['nextAttemptAtMs', row.nextAttemptAtMs],
|
||||
['leaseExpiresAtMs', row.leaseExpiresAtMs],
|
||||
['lastDispatchedAtMs', row.lastDispatchedAtMs],
|
||||
] as const) {
|
||||
if (
|
||||
value !== null &&
|
||||
(!Number.isSafeInteger(Number(value)) || Number(value) < 0)
|
||||
) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error(`Invalid cancellation dispatch ${name}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
const status = row.status as CancellationDispatchStatus;
|
||||
const hasCompleteLease =
|
||||
row.leaseOwner !== null &&
|
||||
row.leaseToken !== null &&
|
||||
row.leaseExpiresAtMs !== null;
|
||||
const hasAnyLease =
|
||||
row.leaseOwner !== null ||
|
||||
row.leaseToken !== null ||
|
||||
row.leaseExpiresAtMs !== null;
|
||||
if (
|
||||
(status === 'leased' && !hasCompleteLease) ||
|
||||
(status !== 'leased' && hasAnyLease) ||
|
||||
((status === 'pending' || status === 'retry_wait') &&
|
||||
row.nextAttemptAtMs === null) ||
|
||||
((status === 'dispatched' || status === 'blocked' || status === 'leased') &&
|
||||
row.nextAttemptAtMs !== null) ||
|
||||
((status === 'dispatched' || status === 'blocked') &&
|
||||
row.lastResult === null)
|
||||
) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error('Cancellation dispatch lease/status fields are inconsistent'),
|
||||
);
|
||||
}
|
||||
return {
|
||||
runId: row.runId,
|
||||
attemptId: row.attemptId,
|
||||
status,
|
||||
version: Number(row.version),
|
||||
dispatchCount: Number(row.dispatchCount),
|
||||
createdAtMs: Number(row.createdAtMs),
|
||||
updatedAtMs: Number(row.updatedAtMs),
|
||||
...(row.nextAttemptAtMs === null
|
||||
? {}
|
||||
: { nextAttemptAtMs: Number(row.nextAttemptAtMs) }),
|
||||
...(row.leaseOwner === null ? {} : { leaseOwner: row.leaseOwner }),
|
||||
...(row.leaseToken === null
|
||||
? {}
|
||||
: { leaseTokenDigest: digestLeaseToken(row.leaseToken) }),
|
||||
...(row.leaseExpiresAtMs === null
|
||||
? {}
|
||||
: { leaseExpiresAtMs: Number(row.leaseExpiresAtMs) }),
|
||||
...(row.lastResult === null
|
||||
? {}
|
||||
: { lastResult: row.lastResult as CancellationDispatchResult }),
|
||||
...(row.lastDispatchedAtMs === null
|
||||
? {}
|
||||
: { lastDispatchedAtMs: Number(row.lastDispatchedAtMs) }),
|
||||
};
|
||||
}
|
||||
|
||||
function resultState(result: CancellationDispatchResult): {
|
||||
status: CancellationDispatchStatus;
|
||||
eventType: string;
|
||||
} {
|
||||
if (RETRYABLE_RESULTS.includes(result)) {
|
||||
return { status: 'retry_wait', eventType: 'run.cancel_dispatch_failed' };
|
||||
}
|
||||
if (BLOCKING_RESULTS.includes(result)) {
|
||||
return { status: 'blocked', eventType: 'run.cancel_dispatch_blocked' };
|
||||
}
|
||||
return { status: 'dispatched', eventType: 'run.cancel_dispatched' };
|
||||
}
|
||||
|
||||
function withoutScheduleAndLease(
|
||||
dispatch: CancellationDispatchRecord,
|
||||
): Omit<
|
||||
CancellationDispatchRecord,
|
||||
| 'nextAttemptAtMs'
|
||||
| 'leaseOwner'
|
||||
| 'leaseTokenDigest'
|
||||
| 'leaseExpiresAtMs'
|
||||
> {
|
||||
const {
|
||||
nextAttemptAtMs: _nextAttemptAtMs,
|
||||
leaseOwner: _leaseOwner,
|
||||
leaseTokenDigest: _leaseTokenDigest,
|
||||
leaseExpiresAtMs: _leaseExpiresAtMs,
|
||||
...rest
|
||||
} = dispatch;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export class LegacySequelizeCancellationDispatchRepository
|
||||
implements CancellationDispatchRepository
|
||||
{
|
||||
private readonly dispatch: ModelStatic<CancellationDispatchInstance>;
|
||||
private readonly run: ModelStatic<CancellationDispatchRunInstance>;
|
||||
private readonly attempt: ModelStatic<CancellationDispatchAttemptInstance>;
|
||||
private readonly event: ModelStatic<CancellationDispatchEventInstance>;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
options: LegacySequelizeCancellationDispatchRepositoryOptions = {},
|
||||
) {
|
||||
this.dispatch = defineDispatchModel(database);
|
||||
this.run = defineRunModel(database);
|
||||
this.attempt = defineAttemptModel(database);
|
||||
this.event = defineEventModel(database);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
async findByRunId(runId: string): Promise<CancellationDispatchRecord | null> {
|
||||
assertId('runId', runId);
|
||||
const row = (await this.dispatch.findByPk(runId, {
|
||||
raw: true,
|
||||
})) as unknown as CancellationDispatchRow | null;
|
||||
return row === null ? null : rowToDispatch(row);
|
||||
}
|
||||
|
||||
async claim(
|
||||
command: ClaimCancellationDispatchCommand,
|
||||
): Promise<ClaimCancellationDispatchResult> {
|
||||
assertClaim(command);
|
||||
return this.database.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const nowMs = this.now();
|
||||
const [run, attempt] = await Promise.all([
|
||||
this.run.findByPk(command.runId, { raw: true, transaction }),
|
||||
this.attempt.findByPk(command.attemptId, { raw: true, transaction }),
|
||||
]);
|
||||
const runRow = run as unknown as CancellationDispatchRunRow | null;
|
||||
const attemptRow =
|
||||
attempt as unknown as CancellationDispatchAttemptRow | null;
|
||||
if (
|
||||
runRow === null ||
|
||||
attemptRow === null ||
|
||||
runRow.executionOwner !== 'runtime' ||
|
||||
!ACTIVE_RUN_STATUSES.includes(runRow.status as RunStatus) ||
|
||||
runRow.cancelRequestedAtMs === null ||
|
||||
Number(runRow.cancelRequestedAtMs) !== command.requestedAtMs ||
|
||||
attemptRow.runId !== command.runId ||
|
||||
!ACTIVE_ATTEMPT_STATUSES.includes(
|
||||
attemptRow.status as (typeof ACTIVE_ATTEMPT_STATUSES)[number],
|
||||
)
|
||||
) {
|
||||
return { status: 'not_eligible' as const };
|
||||
}
|
||||
|
||||
let row = (await this.dispatch.findByPk(command.runId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
})) as unknown as CancellationDispatchRow | null;
|
||||
if (row === null) {
|
||||
try {
|
||||
const created = await this.dispatch.create(
|
||||
{
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
status: 'pending',
|
||||
version: 0,
|
||||
dispatchCount: 0,
|
||||
nextAttemptAtMs: command.requestedAtMs,
|
||||
leaseOwner: null,
|
||||
leaseToken: null,
|
||||
leaseExpiresAtMs: null,
|
||||
lastResult: null,
|
||||
lastDispatchedAtMs: null,
|
||||
createdAtMs: nowMs,
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
row = created.get({ plain: true }) as CancellationDispatchRow;
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
throw new CancellationDispatchRepositoryError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (row.attemptId !== command.attemptId) {
|
||||
throw new CancellationDispatchBindingConflictError(
|
||||
command.runId,
|
||||
command.attemptId,
|
||||
);
|
||||
}
|
||||
const dispatch = rowToDispatch(row);
|
||||
if (dispatch.status === 'dispatched' || dispatch.status === 'blocked') {
|
||||
return { status: dispatch.status, dispatch };
|
||||
}
|
||||
if (
|
||||
dispatch.status === 'leased' &&
|
||||
dispatch.leaseExpiresAtMs !== undefined &&
|
||||
dispatch.leaseExpiresAtMs > nowMs
|
||||
) {
|
||||
return { status: 'leased', dispatch };
|
||||
}
|
||||
if (
|
||||
dispatch.status !== 'leased' &&
|
||||
dispatch.nextAttemptAtMs !== undefined &&
|
||||
dispatch.nextAttemptAtMs > nowMs
|
||||
) {
|
||||
return { status: 'not_due', dispatch };
|
||||
}
|
||||
|
||||
const nextVersion = dispatch.version + 1;
|
||||
const nextCount = dispatch.dispatchCount + 1;
|
||||
const leaseExpiresAtMs = nowMs + command.leaseDurationMs;
|
||||
if (!Number.isSafeInteger(leaseExpiresAtMs)) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error('Cancellation dispatch lease expiry overflowed'),
|
||||
);
|
||||
}
|
||||
const [affected] = await this.dispatch.update(
|
||||
{
|
||||
status: 'leased',
|
||||
version: nextVersion,
|
||||
dispatchCount: nextCount,
|
||||
nextAttemptAtMs: null,
|
||||
leaseOwner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
leaseExpiresAtMs,
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
{
|
||||
where: { runId: command.runId, version: dispatch.version },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (affected !== 1) {
|
||||
throw new CancellationDispatchFenceRejectedError(command.runId);
|
||||
}
|
||||
return {
|
||||
status: 'claimed',
|
||||
leaseToken: command.leaseToken,
|
||||
dispatch: {
|
||||
...withoutScheduleAndLease(dispatch),
|
||||
status: 'leased',
|
||||
version: nextVersion,
|
||||
dispatchCount: nextCount,
|
||||
leaseOwner: command.owner,
|
||||
leaseTokenDigest: digestLeaseToken(command.leaseToken),
|
||||
leaseExpiresAtMs,
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async recordResult(
|
||||
command: RecordCancellationDispatchResultCommand,
|
||||
): Promise<RecordCancellationDispatchResult> {
|
||||
assertRecordResult(command);
|
||||
return this.database.transaction(
|
||||
{ type: Transaction.TYPES.IMMEDIATE },
|
||||
async (transaction) => {
|
||||
const atMs = this.now();
|
||||
const row = (await this.dispatch.findByPk(command.runId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
})) as unknown as CancellationDispatchRow | null;
|
||||
if (
|
||||
row === null ||
|
||||
row.attemptId !== command.attemptId ||
|
||||
row.status !== 'leased' ||
|
||||
row.version !== command.expectedVersion ||
|
||||
row.leaseOwner !== command.owner ||
|
||||
row.leaseToken !== command.leaseToken
|
||||
) {
|
||||
throw new CancellationDispatchFenceRejectedError(command.runId);
|
||||
}
|
||||
const run = (await this.run.findByPk(command.runId, {
|
||||
raw: true,
|
||||
transaction,
|
||||
})) as unknown as CancellationDispatchRunRow | null;
|
||||
if (run === null) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error('Run disappeared while recording cancellation result'),
|
||||
);
|
||||
}
|
||||
const state = resultState(command.result);
|
||||
const controllerInvoked = ![
|
||||
'controller_missing',
|
||||
'handle_missing',
|
||||
].includes(command.result);
|
||||
const nextVersion = row.version + 1;
|
||||
const nextSequence = Number(run.eventSequence) + 1;
|
||||
const nextAttemptAtMs =
|
||||
command.retryDelayMs === undefined
|
||||
? undefined
|
||||
: atMs + command.retryDelayMs;
|
||||
if (
|
||||
nextAttemptAtMs !== undefined &&
|
||||
!Number.isSafeInteger(nextAttemptAtMs)
|
||||
) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error('Cancellation dispatch retry deadline overflowed'),
|
||||
);
|
||||
}
|
||||
const [runAffected] = await this.run.update(
|
||||
{ version: Number(run.version) + 1, eventSequence: nextSequence },
|
||||
{ where: { id: command.runId, version: run.version }, transaction },
|
||||
);
|
||||
if (runAffected !== 1) {
|
||||
throw new CancellationDispatchFenceRejectedError(command.runId);
|
||||
}
|
||||
const [dispatchAffected] = await this.dispatch.update(
|
||||
{
|
||||
status: state.status,
|
||||
version: nextVersion,
|
||||
nextAttemptAtMs: nextAttemptAtMs ?? null,
|
||||
leaseOwner: null,
|
||||
leaseToken: null,
|
||||
leaseExpiresAtMs: null,
|
||||
lastResult: command.result,
|
||||
lastDispatchedAtMs: controllerInvoked
|
||||
? atMs
|
||||
: row.lastDispatchedAtMs,
|
||||
updatedAtMs: atMs,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
runId: command.runId,
|
||||
attemptId: command.attemptId,
|
||||
status: 'leased',
|
||||
version: command.expectedVersion,
|
||||
leaseOwner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (dispatchAffected !== 1) {
|
||||
throw new CancellationDispatchFenceRejectedError(command.runId);
|
||||
}
|
||||
const event: RunEventRecord = {
|
||||
id: command.eventId,
|
||||
runId: command.runId,
|
||||
sequence: nextSequence,
|
||||
type: state.eventType,
|
||||
dedupeKey: `cancel-dispatch:${command.attemptId}:${row.dispatchCount}`,
|
||||
actorType: 'worker',
|
||||
actorId: command.owner,
|
||||
attemptId: command.attemptId,
|
||||
payload: {
|
||||
attempt_id: command.attemptId,
|
||||
dispatch_count: row.dispatchCount,
|
||||
result: command.result,
|
||||
},
|
||||
createdAtMs: atMs,
|
||||
};
|
||||
await this.event.create(
|
||||
{
|
||||
id: event.id,
|
||||
runId: event.runId,
|
||||
sequence: event.sequence,
|
||||
type: event.type,
|
||||
dedupeKey: event.dedupeKey!,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId!,
|
||||
attemptId: event.attemptId!,
|
||||
payload: event.payload,
|
||||
createdAtMs: event.createdAtMs,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
return {
|
||||
dispatch: {
|
||||
...withoutScheduleAndLease(rowToDispatch(row)),
|
||||
status: state.status,
|
||||
version: nextVersion,
|
||||
...(nextAttemptAtMs === undefined
|
||||
? {}
|
||||
: { nextAttemptAtMs }),
|
||||
lastResult: command.result,
|
||||
...(controllerInvoked
|
||||
? { lastDispatchedAtMs: atMs }
|
||||
: row.lastDispatchedAtMs === null
|
||||
? {}
|
||||
: { lastDispatchedAtMs: Number(row.lastDispatchedAtMs) }),
|
||||
updatedAtMs: atMs,
|
||||
},
|
||||
event,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const nowMs = this.clock();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new CancellationDispatchRepositoryError(
|
||||
new Error('Cancellation dispatch repository clock is invalid'),
|
||||
);
|
||||
}
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
ModelStatic,
|
||||
Op,
|
||||
Sequelize,
|
||||
UniqueConstraintError,
|
||||
type WhereOptions,
|
||||
} from 'sequelize';
|
||||
import { RUN_ATTEMPT_TABLE } from '../../../migrations/0002-run-schema';
|
||||
import { COMPLETION_RECEIPT_JOURNAL_TABLE } from '../../../migrations/0007-completion-receipt-journal';
|
||||
import {
|
||||
COMPLETION_RECEIPT_JOURNAL_STATES,
|
||||
type CompletionReceiptJournalCandidate,
|
||||
type CompletionReceiptJournalCursor,
|
||||
type CompletionReceiptJournalRecord,
|
||||
type CompletionReceiptJournalState,
|
||||
} from '../../domain/completionReceiptJournal';
|
||||
import { assertCompletionReceiptId } from '../../domain/completionReceipt';
|
||||
import type { RunAttemptStatus } from '../../domain/run';
|
||||
import {
|
||||
MAX_COMPLETION_RECEIPT_JOURNAL_BATCH_SIZE,
|
||||
type CompletionReceiptJournal,
|
||||
type QuarantineCompletionReceiptCommand,
|
||||
type RegisterCompletionReceiptCommand,
|
||||
} from '../../ports/completionReceiptJournal';
|
||||
|
||||
interface JournalRow {
|
||||
attemptId: string;
|
||||
runId: string;
|
||||
state: string;
|
||||
quarantineRef: string | null;
|
||||
purgeAfterMs: number | null;
|
||||
registeredAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
interface AttemptRow {
|
||||
id: string;
|
||||
status: string;
|
||||
executorType: string;
|
||||
finishedAtMs: number | null;
|
||||
}
|
||||
|
||||
interface JournalInstance extends Model<JournalRow, JournalRow>, JournalRow {}
|
||||
interface AttemptInstance extends Model<AttemptRow, AttemptRow>, AttemptRow {}
|
||||
|
||||
function defineJournalModel(database: Sequelize): ModelStatic<JournalInstance> {
|
||||
return database.define<JournalInstance>(
|
||||
'Ql3CompletionReceiptJournal',
|
||||
{
|
||||
attemptId: {
|
||||
field: 'attempt_id',
|
||||
type: DataTypes.STRING(36),
|
||||
primaryKey: true,
|
||||
},
|
||||
runId: {
|
||||
field: 'run_id',
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: false,
|
||||
},
|
||||
state: { type: DataTypes.STRING(16), allowNull: false },
|
||||
quarantineRef: {
|
||||
field: 'quarantine_ref',
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
purgeAfterMs: {
|
||||
field: 'purge_after_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
registeredAtMs: {
|
||||
field: 'registered_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAtMs: {
|
||||
field: 'updated_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: COMPLETION_RECEIPT_JOURNAL_TABLE,
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function defineAttemptModel(database: Sequelize): ModelStatic<AttemptInstance> {
|
||||
return database.define<AttemptInstance>(
|
||||
'Ql3CompletionReceiptJournalAttempt',
|
||||
{
|
||||
id: { type: DataTypes.STRING(36), primaryKey: true },
|
||||
status: { type: DataTypes.STRING(32), allowNull: false },
|
||||
executorType: {
|
||||
field: 'executor_type',
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
finishedAtMs: {
|
||||
field: 'finished_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{ tableName: RUN_ATTEMPT_TABLE, timestamps: false, freezeTableName: true },
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonNegativeTimestamp(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCursor(cursor: CompletionReceiptJournalCursor): void {
|
||||
assertNonNegativeTimestamp('cursor.updatedAtMs', cursor.updatedAtMs);
|
||||
assertCompletionReceiptId(cursor.attemptId, 'attemptId');
|
||||
}
|
||||
|
||||
function assertQuarantineRef(value: string): void {
|
||||
if (
|
||||
value.length < 1 ||
|
||||
value.length > 255 ||
|
||||
!value.startsWith('.quarantine/') ||
|
||||
value.includes('..') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new TypeError('quarantineRef is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(row: JournalRow): CompletionReceiptJournalRecord {
|
||||
if (!COMPLETION_RECEIPT_JOURNAL_STATES.includes(row.state as never)) {
|
||||
throw new Error('Completion receipt journal state is corrupt');
|
||||
}
|
||||
return {
|
||||
attemptId: row.attemptId,
|
||||
runId: row.runId,
|
||||
state: row.state as CompletionReceiptJournalState,
|
||||
registeredAtMs: Number(row.registeredAtMs),
|
||||
updatedAtMs: Number(row.updatedAtMs),
|
||||
...(row.quarantineRef === null ? {} : { quarantineRef: row.quarantineRef }),
|
||||
...(row.purgeAfterMs === null
|
||||
? {}
|
||||
: { purgeAfterMs: Number(row.purgeAfterMs) }),
|
||||
};
|
||||
}
|
||||
|
||||
export class LegacySequelizeCompletionReceiptJournal
|
||||
implements CompletionReceiptJournal
|
||||
{
|
||||
private readonly journal: ModelStatic<JournalInstance>;
|
||||
private readonly attempt: ModelStatic<AttemptInstance>;
|
||||
|
||||
constructor(database: Sequelize) {
|
||||
this.journal = defineJournalModel(database);
|
||||
this.attempt = defineAttemptModel(database);
|
||||
}
|
||||
|
||||
async register(command: RegisterCompletionReceiptCommand): Promise<void> {
|
||||
assertCompletionReceiptId(command.attemptId, 'attemptId');
|
||||
assertCompletionReceiptId(command.runId, 'runId');
|
||||
assertNonNegativeTimestamp('registeredAtMs', command.registeredAtMs);
|
||||
const values: JournalRow = {
|
||||
attemptId: command.attemptId,
|
||||
runId: command.runId,
|
||||
state: 'pending',
|
||||
quarantineRef: null,
|
||||
purgeAfterMs: null,
|
||||
registeredAtMs: command.registeredAtMs,
|
||||
updatedAtMs: command.registeredAtMs,
|
||||
};
|
||||
try {
|
||||
await this.journal.create(values);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof UniqueConstraintError)) throw error;
|
||||
}
|
||||
const current = (await this.journal.findByPk(command.attemptId, {
|
||||
raw: true,
|
||||
})) as unknown as JournalRow | null;
|
||||
if (
|
||||
!current ||
|
||||
current.runId !== command.runId ||
|
||||
Number(current.registeredAtMs) !== command.registeredAtMs
|
||||
) {
|
||||
throw new Error('Completion receipt journal registration conflicts');
|
||||
}
|
||||
}
|
||||
|
||||
async markQuarantined(
|
||||
command: QuarantineCompletionReceiptCommand,
|
||||
): Promise<void> {
|
||||
assertCompletionReceiptId(command.attemptId, 'attemptId');
|
||||
assertQuarantineRef(command.quarantineRef);
|
||||
assertNonNegativeTimestamp('updatedAtMs', command.updatedAtMs);
|
||||
assertNonNegativeTimestamp('purgeAfterMs', command.purgeAfterMs);
|
||||
if (command.purgeAfterMs < command.updatedAtMs) {
|
||||
throw new RangeError('purgeAfterMs must not precede updatedAtMs');
|
||||
}
|
||||
const [updated] = await this.journal.update(
|
||||
{
|
||||
state: 'quarantined',
|
||||
quarantineRef: command.quarantineRef,
|
||||
purgeAfterMs: command.purgeAfterMs,
|
||||
updatedAtMs: command.updatedAtMs,
|
||||
},
|
||||
{ where: { attemptId: command.attemptId, state: 'pending' } },
|
||||
);
|
||||
if (updated === 1) return;
|
||||
const current = (await this.journal.findByPk(command.attemptId, {
|
||||
raw: true,
|
||||
})) as unknown as JournalRow | null;
|
||||
if (
|
||||
current?.state === 'quarantined' &&
|
||||
current.quarantineRef === command.quarantineRef &&
|
||||
Number(current.purgeAfterMs) === command.purgeAfterMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error('Completion receipt journal quarantine transition failed');
|
||||
}
|
||||
|
||||
async resolve(attemptId: string): Promise<boolean> {
|
||||
assertCompletionReceiptId(attemptId, 'attemptId');
|
||||
return (await this.journal.destroy({ where: { attemptId } })) === 1;
|
||||
}
|
||||
|
||||
async listCandidates({
|
||||
observedAtMs,
|
||||
cursor,
|
||||
limit = 32,
|
||||
}: {
|
||||
observedAtMs: number;
|
||||
cursor?: CompletionReceiptJournalCursor;
|
||||
limit?: number;
|
||||
}) {
|
||||
assertNonNegativeTimestamp('observedAtMs', observedAtMs);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_COMPLETION_RECEIPT_JOURNAL_BATCH_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
'limit must be between 1 and MAX_COMPLETION_RECEIPT_JOURNAL_BATCH_SIZE',
|
||||
);
|
||||
}
|
||||
if (cursor) assertCursor(cursor);
|
||||
|
||||
const eligible: WhereOptions<JournalRow> = {
|
||||
[Op.or]: [
|
||||
{ state: 'pending' },
|
||||
{
|
||||
state: 'quarantined',
|
||||
purgeAfterMs: { [Op.lte]: observedAtMs },
|
||||
},
|
||||
],
|
||||
};
|
||||
const afterCursor: WhereOptions<JournalRow> | undefined = cursor
|
||||
? {
|
||||
[Op.or]: [
|
||||
{ updatedAtMs: { [Op.gt]: cursor.updatedAtMs } },
|
||||
{
|
||||
updatedAtMs: cursor.updatedAtMs,
|
||||
attemptId: { [Op.gt]: cursor.attemptId },
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
const where: WhereOptions<JournalRow> = afterCursor
|
||||
? { [Op.and]: [eligible, afterCursor] }
|
||||
: eligible;
|
||||
const rows = (await this.journal.findAll({
|
||||
where,
|
||||
order: [
|
||||
['updatedAtMs', 'ASC'],
|
||||
['attemptId', 'ASC'],
|
||||
],
|
||||
limit: limit + 1,
|
||||
raw: true,
|
||||
})) as unknown as JournalRow[];
|
||||
const truncated = rows.length > limit;
|
||||
const bounded = rows.slice(0, limit);
|
||||
if (bounded.length === 0) return { candidates: [], truncated: false };
|
||||
|
||||
const attempts = (await this.attempt.findAll({
|
||||
where: { id: { [Op.in]: bounded.map((row) => row.attemptId) } },
|
||||
raw: true,
|
||||
})) as unknown as AttemptRow[];
|
||||
const attemptById = new Map(attempts.map((row) => [row.id, row]));
|
||||
const candidates: CompletionReceiptJournalCandidate[] = bounded.map(
|
||||
(row) => {
|
||||
const attempt = attemptById.get(row.attemptId);
|
||||
if (!attempt) {
|
||||
throw new Error('Completion receipt journal Attempt is missing');
|
||||
}
|
||||
return {
|
||||
...toRecord(row),
|
||||
attemptStatus: attempt.status as RunAttemptStatus,
|
||||
executorType: attempt.executorType,
|
||||
...(attempt.finishedAtMs === null
|
||||
? {}
|
||||
: { finishedAtMs: Number(attempt.finishedAtMs) }),
|
||||
};
|
||||
},
|
||||
);
|
||||
const last = bounded[bounded.length - 1];
|
||||
return {
|
||||
candidates,
|
||||
truncated,
|
||||
nextCursor: {
|
||||
updatedAtMs: Number(last.updatedAtMs),
|
||||
attemptId: last.attemptId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
import {
|
||||
IDENTITY_AUTHENTICATION_BINDING_TABLE,
|
||||
IDENTITY_SUBJECT_TABLE,
|
||||
} from '../../../migrations/0019-identity-directory';
|
||||
import {
|
||||
IdentityDirectoryUnavailableError,
|
||||
assertIdentityProvider,
|
||||
assertIdentityProviderSubject,
|
||||
normalizeIdentityAuthenticationBindingRecord,
|
||||
normalizeIdentitySubjectRecord,
|
||||
} from '../../domain/identityDirectory';
|
||||
import type { PolicySubject } from '../../domain/projectPolicy';
|
||||
import type { IdentityDirectoryRepository } from '../../ports/identityDirectoryRepository';
|
||||
|
||||
interface IdentityAuthenticationRow {
|
||||
provider: string;
|
||||
provider_subject: string;
|
||||
binding_version: number;
|
||||
binding_state: string;
|
||||
binding_subject_id: string;
|
||||
binding_created_at_ms: number | string;
|
||||
subject_id: string | null;
|
||||
subject_type: string | null;
|
||||
subject_status: string | null;
|
||||
subject_version: number | null;
|
||||
subject_created_at_ms: number | string | null;
|
||||
subject_updated_at_ms: number | string | null;
|
||||
}
|
||||
|
||||
export class LegacySequelizeIdentityDirectoryRepository
|
||||
implements IdentityDirectoryRepository
|
||||
{
|
||||
constructor(private readonly database: Sequelize) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Identity directory repository is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAuthenticationSubject(
|
||||
provider: string,
|
||||
providerSubject: string,
|
||||
): Promise<Readonly<PolicySubject> | null> {
|
||||
assertIdentityProvider(provider);
|
||||
assertIdentityProviderSubject(providerSubject);
|
||||
try {
|
||||
const rows = await this.database.query<IdentityAuthenticationRow>(
|
||||
`SELECT binding.provider AS provider,
|
||||
binding.provider_subject AS provider_subject,
|
||||
binding.version AS binding_version,
|
||||
binding.state AS binding_state,
|
||||
binding.subject_id AS binding_subject_id,
|
||||
binding.created_at_ms AS binding_created_at_ms,
|
||||
subject.id AS subject_id,
|
||||
subject.type AS subject_type,
|
||||
subject.status AS subject_status,
|
||||
subject.version AS subject_version,
|
||||
subject.created_at_ms AS subject_created_at_ms,
|
||||
subject.updated_at_ms AS subject_updated_at_ms
|
||||
FROM "${IDENTITY_AUTHENTICATION_BINDING_TABLE}" AS binding
|
||||
LEFT JOIN "${IDENTITY_SUBJECT_TABLE}" AS subject
|
||||
ON subject.id = binding.subject_id
|
||||
WHERE binding.provider = :provider
|
||||
AND binding.provider_subject = :providerSubject
|
||||
AND binding.version = (
|
||||
SELECT MAX(current.version)
|
||||
FROM "${IDENTITY_AUTHENTICATION_BINDING_TABLE}" AS current
|
||||
WHERE current.provider = binding.provider
|
||||
AND current.provider_subject = binding.provider_subject
|
||||
)
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { provider, providerSubject },
|
||||
},
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length !== 1) throw new IdentityDirectoryUnavailableError();
|
||||
const row = rows[0];
|
||||
const binding = normalizeIdentityAuthenticationBindingRecord({
|
||||
provider: row.provider,
|
||||
providerSubject: row.provider_subject,
|
||||
version: Number(row.binding_version),
|
||||
state: row.binding_state as 'active' | 'revoked',
|
||||
subjectId: row.binding_subject_id,
|
||||
createdAtMs: Number(row.binding_created_at_ms),
|
||||
});
|
||||
const subject = normalizeIdentitySubjectRecord({
|
||||
subject: {
|
||||
type: row.subject_type as PolicySubject['type'],
|
||||
id: row.subject_id!,
|
||||
},
|
||||
status: row.subject_status as 'active' | 'disabled',
|
||||
version: Number(row.subject_version),
|
||||
createdAtMs: Number(row.subject_created_at_ms),
|
||||
updatedAtMs: Number(row.subject_updated_at_ms),
|
||||
});
|
||||
if (binding.subjectId !== subject.subject.id) {
|
||||
throw new IdentityDirectoryUnavailableError();
|
||||
}
|
||||
if (
|
||||
binding.state !== 'active' ||
|
||||
subject.status !== 'active' ||
|
||||
subject.subject.type !== 'user'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return subject.subject;
|
||||
} catch (error) {
|
||||
if (error instanceof IdentityDirectoryUnavailableError) throw error;
|
||||
throw new IdentityDirectoryUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { QueryTypes, type Sequelize } from 'sequelize';
|
||||
import {
|
||||
RUN_ATTEMPT_TABLE,
|
||||
RUN_TABLE,
|
||||
} from '../../../migrations/0002-run-schema';
|
||||
import { RUNNING_INSTANCE_TABLE } from '../../../migrations/0003-running-instance-run-reference';
|
||||
import type {
|
||||
ExecutionOrigin,
|
||||
RunAttemptStatus,
|
||||
RunStatus,
|
||||
} from '../../domain/run';
|
||||
import {
|
||||
MAX_LEGACY_SHADOW_STARTUP_BATCH_SIZE,
|
||||
MAX_LEGACY_SHADOW_STARTUP_EVIDENCE,
|
||||
type LegacyRunningInstanceEvidence,
|
||||
type LegacyRunningInstanceEvidencePage,
|
||||
type LegacyShadowStartupCandidate,
|
||||
type LegacyShadowStartupCursor,
|
||||
type LegacyShadowStartupPage,
|
||||
type LegacyShadowStartupRecoverySource,
|
||||
} from '../../ports/legacyShadowStartupRecovery';
|
||||
|
||||
interface RunRow {
|
||||
runId: string;
|
||||
legacyCronId: number | null;
|
||||
origin: string;
|
||||
runStatus: string;
|
||||
createdAtMs: number | string;
|
||||
}
|
||||
|
||||
interface AttemptCountRow {
|
||||
runId: string;
|
||||
activeAttemptCount: number | string;
|
||||
}
|
||||
|
||||
interface AttemptRow {
|
||||
attemptId: string;
|
||||
runId: string;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
logArtifactId: string | null;
|
||||
createdAtMs: number | string;
|
||||
startedAtMs: number | string | null;
|
||||
}
|
||||
|
||||
interface RunningInstanceRow {
|
||||
pid: number | null;
|
||||
logPath: string | null;
|
||||
startedAt: number | string;
|
||||
finishedAt: number | string | null;
|
||||
status: number | string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export type LegacyLogArtifactIdFactory = (logPath: string) => string;
|
||||
|
||||
function safeInteger(value: number | string, name: string): number {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function secondsToMilliseconds(value: number | string, name: string): number {
|
||||
const seconds = safeInteger(value, name);
|
||||
const milliseconds = seconds * 1_000;
|
||||
if (!Number.isSafeInteger(milliseconds)) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(
|
||||
value: number | null,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
if (value === null) return undefined;
|
||||
const parsed = safeInteger(value, name);
|
||||
if (parsed < 1) throw new TypeError(`${name} is invalid`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertLimit(limit: number, maximum: number): void {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) {
|
||||
throw new RangeError(`limit must be between 1 and ${maximum}`);
|
||||
}
|
||||
}
|
||||
|
||||
function outcome(status: number): LegacyRunningInstanceEvidence['outcome'] {
|
||||
if (status === 0) return 'running';
|
||||
if (status === 1) return 'succeeded';
|
||||
if (status === 2) return 'stopped';
|
||||
if (status === 3) return 'failed';
|
||||
throw new TypeError('RunningInstance status is invalid');
|
||||
}
|
||||
|
||||
export class LegacySequelizeShadowStartupRecoverySource
|
||||
implements LegacyShadowStartupRecoverySource
|
||||
{
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly createLogArtifactId: LegacyLogArtifactIdFactory,
|
||||
) {}
|
||||
|
||||
async listCandidates({
|
||||
origins,
|
||||
cursor,
|
||||
limit = MAX_LEGACY_SHADOW_STARTUP_BATCH_SIZE,
|
||||
}: {
|
||||
origins: readonly ExecutionOrigin[];
|
||||
cursor?: LegacyShadowStartupCursor;
|
||||
limit?: number;
|
||||
}): Promise<LegacyShadowStartupPage> {
|
||||
assertLimit(limit, MAX_LEGACY_SHADOW_STARTUP_BATCH_SIZE);
|
||||
const enabledOrigins = [...new Set(origins)];
|
||||
if (enabledOrigins.length === 0) {
|
||||
return { candidates: [], truncated: false };
|
||||
}
|
||||
if (
|
||||
cursor !== undefined &&
|
||||
(!Number.isSafeInteger(cursor.createdAtMs) ||
|
||||
cursor.createdAtMs < 0 ||
|
||||
cursor.runId.length === 0)
|
||||
) {
|
||||
throw new TypeError('Legacy Shadow startup cursor is invalid');
|
||||
}
|
||||
|
||||
const runRows = await this.database.query<RunRow>(
|
||||
`SELECT
|
||||
id AS "runId",
|
||||
legacy_cron_id AS "legacyCronId",
|
||||
execution_origin AS origin,
|
||||
status AS "runStatus",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM ${RUN_TABLE}
|
||||
WHERE execution_owner = 'legacy'
|
||||
AND execution_origin IN (:origins)
|
||||
AND status IN ('queued', 'dispatching', 'running')
|
||||
${
|
||||
cursor === undefined
|
||||
? ''
|
||||
: `AND (
|
||||
created_at_ms > :cursorCreatedAtMs OR
|
||||
(created_at_ms = :cursorCreatedAtMs AND id > :cursorRunId)
|
||||
)`
|
||||
}
|
||||
ORDER BY created_at_ms ASC, id ASC
|
||||
LIMIT :fetchLimit`,
|
||||
{
|
||||
replacements: {
|
||||
origins: enabledOrigins,
|
||||
fetchLimit: limit + 1,
|
||||
...(cursor === undefined
|
||||
? {}
|
||||
: {
|
||||
cursorCreatedAtMs: cursor.createdAtMs,
|
||||
cursorRunId: cursor.runId,
|
||||
}),
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const truncated = runRows.length > limit;
|
||||
const boundedRuns = runRows.slice(0, limit);
|
||||
if (boundedRuns.length === 0) {
|
||||
return { candidates: [], truncated };
|
||||
}
|
||||
|
||||
const runIds = boundedRuns.map((run) => run.runId);
|
||||
const countRows = await this.database.query<AttemptCountRow>(
|
||||
`SELECT run_id AS "runId", COUNT(*) AS "activeAttemptCount"
|
||||
FROM ${RUN_ATTEMPT_TABLE}
|
||||
WHERE run_id IN (:runIds)
|
||||
AND status IN ('claimed', 'starting', 'running')
|
||||
GROUP BY run_id`,
|
||||
{
|
||||
replacements: { runIds },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const counts = new Map(
|
||||
countRows.map((row) => [
|
||||
row.runId,
|
||||
safeInteger(row.activeAttemptCount, 'activeAttemptCount'),
|
||||
]),
|
||||
);
|
||||
const singleAttemptRunIds = runIds.filter(
|
||||
(runId) => counts.get(runId) === 1,
|
||||
);
|
||||
const attemptRows =
|
||||
singleAttemptRunIds.length === 0
|
||||
? []
|
||||
: await this.database.query<AttemptRow>(
|
||||
`SELECT
|
||||
id AS "attemptId",
|
||||
run_id AS "runId",
|
||||
status,
|
||||
pid,
|
||||
log_artifact_id AS "logArtifactId",
|
||||
created_at_ms AS "createdAtMs",
|
||||
started_at_ms AS "startedAtMs"
|
||||
FROM ${RUN_ATTEMPT_TABLE}
|
||||
WHERE run_id IN (:runIds)
|
||||
AND status IN ('claimed', 'starting', 'running')`,
|
||||
{
|
||||
replacements: { runIds: singleAttemptRunIds },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const attempts = new Map(attemptRows.map((row) => [row.runId, row]));
|
||||
const candidates: LegacyShadowStartupCandidate[] = boundedRuns.map(
|
||||
(run) => {
|
||||
const activeAttemptCount = counts.get(run.runId) ?? 0;
|
||||
const attempt = attempts.get(run.runId);
|
||||
return {
|
||||
runId: run.runId,
|
||||
...(run.legacyCronId === null
|
||||
? {}
|
||||
: {
|
||||
legacyCronId: optionalPositiveInteger(
|
||||
run.legacyCronId,
|
||||
'legacyCronId',
|
||||
),
|
||||
}),
|
||||
origin: run.origin as ExecutionOrigin,
|
||||
runStatus: run.runStatus as RunStatus,
|
||||
createdAtMs: safeInteger(run.createdAtMs, 'createdAtMs'),
|
||||
activeAttemptCount,
|
||||
...(attempt === undefined
|
||||
? {}
|
||||
: {
|
||||
attempt: {
|
||||
attemptId: attempt.attemptId,
|
||||
status: attempt.status as RunAttemptStatus,
|
||||
...(attempt.pid === null ? {} : { pid: attempt.pid }),
|
||||
...(attempt.logArtifactId === null
|
||||
? {}
|
||||
: { logArtifactId: attempt.logArtifactId }),
|
||||
createdAtMs: safeInteger(
|
||||
attempt.createdAtMs,
|
||||
'attempt.createdAtMs',
|
||||
),
|
||||
...(attempt.startedAtMs === null
|
||||
? {}
|
||||
: {
|
||||
startedAtMs: safeInteger(
|
||||
attempt.startedAtMs,
|
||||
'attempt.startedAtMs',
|
||||
),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return {
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
nextCursor: {
|
||||
createdAtMs: last.createdAtMs,
|
||||
runId: last.runId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async listRunningInstanceEvidence({
|
||||
legacyCronId,
|
||||
limit = MAX_LEGACY_SHADOW_STARTUP_EVIDENCE,
|
||||
}: {
|
||||
legacyCronId: number;
|
||||
limit?: number;
|
||||
}): Promise<LegacyRunningInstanceEvidencePage> {
|
||||
if (!Number.isSafeInteger(legacyCronId) || legacyCronId < 1) {
|
||||
throw new RangeError('legacyCronId must be a positive safe integer');
|
||||
}
|
||||
assertLimit(limit, MAX_LEGACY_SHADOW_STARTUP_EVIDENCE);
|
||||
const rows = await this.database.query<RunningInstanceRow>(
|
||||
`SELECT
|
||||
pid,
|
||||
log_path AS "logPath",
|
||||
started_at AS "startedAt",
|
||||
finished_at AS "finishedAt",
|
||||
status,
|
||||
exit_code AS "exitCode"
|
||||
FROM ${RUNNING_INSTANCE_TABLE}
|
||||
WHERE cron_id = :legacyCronId
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT :fetchLimit`,
|
||||
{
|
||||
replacements: { legacyCronId, fetchLimit: limit + 1 },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const truncated = rows.length > limit;
|
||||
return {
|
||||
evidence: rows.slice(0, limit).map((row) => ({
|
||||
...(row.pid === null ? {} : { pid: row.pid }),
|
||||
...(row.logPath === null
|
||||
? {}
|
||||
: { logArtifactId: this.createLogArtifactId(row.logPath) }),
|
||||
startedAtMs: secondsToMilliseconds(row.startedAt, 'startedAt'),
|
||||
...(row.finishedAt === null
|
||||
? {}
|
||||
: {
|
||||
finishedAtMs: secondsToMilliseconds(row.finishedAt, 'finishedAt'),
|
||||
}),
|
||||
outcome: outcome(safeInteger(row.status, 'status')),
|
||||
...(row.exitCode === null ? {} : { exitCode: row.exitCode }),
|
||||
})),
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { QueryTypes, type Sequelize } from 'sequelize';
|
||||
import {
|
||||
RUN_ATTEMPT_TABLE,
|
||||
RUN_TABLE,
|
||||
} from '../../../migrations/0002-run-schema';
|
||||
import { RUNNING_INSTANCE_TABLE } from '../../../migrations/0003-running-instance-run-reference';
|
||||
import {
|
||||
EXECUTION_ORIGINS,
|
||||
RUN_ATTEMPT_STATUSES,
|
||||
RUN_STATUSES,
|
||||
type ExecutionOrigin,
|
||||
type RunAttemptStatus,
|
||||
type RunStatus,
|
||||
} from '../../domain/run';
|
||||
import {
|
||||
MAX_LEGACY_SHADOW_TERMINAL_EVIDENCE_PER_PAGE,
|
||||
MAX_LEGACY_SHADOW_TERMINAL_PAGE_SIZE,
|
||||
type LegacyShadowTerminalCandidate,
|
||||
type LegacyShadowTerminalCursor,
|
||||
type LegacyShadowTerminalDifferenceSource,
|
||||
type LegacyShadowTerminalPage,
|
||||
type LegacyTerminalEvidence,
|
||||
type LegacyTerminalOutcome,
|
||||
} from '../../ports/legacyShadowTerminalDifference';
|
||||
|
||||
interface CandidateRow {
|
||||
runId: string;
|
||||
legacyCronId: number | null;
|
||||
origin: string;
|
||||
runStatus: string;
|
||||
createdAtMs: number | string;
|
||||
startedAtMs: number | string | null;
|
||||
finishedAtMs: number | string | null;
|
||||
attemptCount: number | string;
|
||||
attemptId: string | null;
|
||||
attemptStatus: string | null;
|
||||
attemptPid: number | null;
|
||||
attemptLogArtifactId: string | null;
|
||||
attemptCreatedAtMs: number | string | null;
|
||||
attemptStartedAtMs: number | string | null;
|
||||
attemptFinishedAtMs: number | string | null;
|
||||
attemptExitCode: number | null;
|
||||
}
|
||||
|
||||
interface EvidenceRow {
|
||||
instanceId: number | string;
|
||||
legacyCronId: number | string;
|
||||
runId: string | null;
|
||||
attemptId: string | null;
|
||||
pid: number | null;
|
||||
logPath: string | null;
|
||||
startedAt: number | string;
|
||||
finishedAt: number | string;
|
||||
status: number | string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export type LegacyTerminalLogArtifactIdFactory = (logPath: string) => string;
|
||||
|
||||
const ORIGINS = new Set<string>(EXECUTION_ORIGINS);
|
||||
const RUN_STATUS_SET = new Set<string>(RUN_STATUSES);
|
||||
const ATTEMPT_STATUS_SET = new Set<string>(RUN_ATTEMPT_STATUSES);
|
||||
|
||||
function safeInteger(value: number | string, name: string): number {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(
|
||||
value: number | null,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
if (value === null) return undefined;
|
||||
const parsed = safeInteger(value, name);
|
||||
if (parsed < 1) throw new TypeError(`${name} is invalid`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function secondsToMilliseconds(value: number | string, name: string): number {
|
||||
const milliseconds = safeInteger(value, name) * 1_000;
|
||||
if (!Number.isSafeInteger(milliseconds)) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
function assertLimit(limit: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_LEGACY_SHADOW_TERMINAL_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`limit must be between 1 and ${MAX_LEGACY_SHADOW_TERMINAL_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runStatus(value: string): RunStatus {
|
||||
if (!RUN_STATUS_SET.has(value)) {
|
||||
throw new TypeError('Run status is invalid');
|
||||
}
|
||||
return value as RunStatus;
|
||||
}
|
||||
|
||||
function attemptStatus(value: string): RunAttemptStatus {
|
||||
if (!ATTEMPT_STATUS_SET.has(value)) {
|
||||
throw new TypeError('Run Attempt status is invalid');
|
||||
}
|
||||
return value as RunAttemptStatus;
|
||||
}
|
||||
|
||||
function origin(value: string): ExecutionOrigin {
|
||||
if (!ORIGINS.has(value)) {
|
||||
throw new TypeError('Run execution origin is invalid');
|
||||
}
|
||||
return value as ExecutionOrigin;
|
||||
}
|
||||
|
||||
function terminalOutcome(value: number): LegacyTerminalOutcome {
|
||||
if (value === 1) return 'succeeded';
|
||||
if (value === 2) return 'stopped';
|
||||
if (value === 3) return 'failed';
|
||||
throw new TypeError('RunningInstance terminal status is invalid');
|
||||
}
|
||||
|
||||
function candidateFromRow(row: CandidateRow): LegacyShadowTerminalCandidate {
|
||||
const attemptCount = safeInteger(row.attemptCount, 'attemptCount');
|
||||
if (
|
||||
(attemptCount === 0 && row.attemptId !== null) ||
|
||||
(attemptCount > 0 &&
|
||||
(row.attemptId === null ||
|
||||
row.attemptStatus === null ||
|
||||
row.attemptCreatedAtMs === null))
|
||||
) {
|
||||
throw new TypeError('Run Attempt projection is invalid');
|
||||
}
|
||||
return {
|
||||
runId: row.runId,
|
||||
...(row.legacyCronId === null
|
||||
? {}
|
||||
: {
|
||||
legacyCronId: optionalPositiveInteger(
|
||||
row.legacyCronId,
|
||||
'legacyCronId',
|
||||
),
|
||||
}),
|
||||
origin: origin(row.origin),
|
||||
runStatus: runStatus(row.runStatus),
|
||||
createdAtMs: safeInteger(row.createdAtMs, 'createdAtMs'),
|
||||
...(row.startedAtMs === null
|
||||
? {}
|
||||
: { startedAtMs: safeInteger(row.startedAtMs, 'startedAtMs') }),
|
||||
...(row.finishedAtMs === null
|
||||
? {}
|
||||
: { finishedAtMs: safeInteger(row.finishedAtMs, 'finishedAtMs') }),
|
||||
attemptCount,
|
||||
...(row.attemptId === null
|
||||
? {}
|
||||
: {
|
||||
attempt: {
|
||||
attemptId: row.attemptId,
|
||||
status: attemptStatus(row.attemptStatus!),
|
||||
...(row.attemptPid === null
|
||||
? {}
|
||||
: { pid: optionalPositiveInteger(row.attemptPid, 'attemptPid') }),
|
||||
...(row.attemptLogArtifactId === null
|
||||
? {}
|
||||
: { logArtifactId: row.attemptLogArtifactId }),
|
||||
createdAtMs: safeInteger(
|
||||
row.attemptCreatedAtMs!,
|
||||
'attemptCreatedAtMs',
|
||||
),
|
||||
...(row.attemptStartedAtMs === null
|
||||
? {}
|
||||
: {
|
||||
startedAtMs: safeInteger(
|
||||
row.attemptStartedAtMs,
|
||||
'attemptStartedAtMs',
|
||||
),
|
||||
}),
|
||||
...(row.attemptFinishedAtMs === null
|
||||
? {}
|
||||
: {
|
||||
finishedAtMs: safeInteger(
|
||||
row.attemptFinishedAtMs,
|
||||
'attemptFinishedAtMs',
|
||||
),
|
||||
}),
|
||||
...(row.attemptExitCode === null
|
||||
? {}
|
||||
: {
|
||||
exitCode: safeInteger(row.attemptExitCode, 'attemptExitCode'),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** SQLite/Sequelize adapter for the explicit, read-only local audit. */
|
||||
export class LegacySequelizeShadowTerminalDifferenceSource
|
||||
implements LegacyShadowTerminalDifferenceSource
|
||||
{
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly createLogArtifactId: LegacyTerminalLogArtifactIdFactory,
|
||||
) {}
|
||||
|
||||
async listCandidates({
|
||||
projectId,
|
||||
origins,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
observedAtMs,
|
||||
correlationToleranceMs,
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
projectId: string;
|
||||
origins: readonly ExecutionOrigin[];
|
||||
windowStartMs: number;
|
||||
windowEndMs: number;
|
||||
observedAtMs: number;
|
||||
correlationToleranceMs: number;
|
||||
cursor?: LegacyShadowTerminalCursor;
|
||||
limit: number;
|
||||
}): Promise<LegacyShadowTerminalPage> {
|
||||
assertLimit(limit);
|
||||
if (origins.length === 0) {
|
||||
return {
|
||||
candidates: [],
|
||||
evidence: [],
|
||||
evidenceTruncated: false,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
const rows = await this.database.query<CandidateRow>(
|
||||
`SELECT
|
||||
r.id AS "runId",
|
||||
r.legacy_cron_id AS "legacyCronId",
|
||||
r.execution_origin AS origin,
|
||||
r.status AS "runStatus",
|
||||
r.created_at_ms AS "createdAtMs",
|
||||
r.started_at_ms AS "startedAtMs",
|
||||
r.finished_at_ms AS "finishedAtMs",
|
||||
(SELECT COUNT(*)
|
||||
FROM ${RUN_ATTEMPT_TABLE} counted
|
||||
WHERE counted.run_id = r.id) AS "attemptCount",
|
||||
attempt.id AS "attemptId",
|
||||
attempt.status AS "attemptStatus",
|
||||
attempt.pid AS "attemptPid",
|
||||
attempt.log_artifact_id AS "attemptLogArtifactId",
|
||||
attempt.created_at_ms AS "attemptCreatedAtMs",
|
||||
attempt.started_at_ms AS "attemptStartedAtMs",
|
||||
attempt.finished_at_ms AS "attemptFinishedAtMs",
|
||||
attempt.exit_code AS "attemptExitCode"
|
||||
FROM ${RUN_TABLE} r
|
||||
LEFT JOIN ${RUN_ATTEMPT_TABLE} attempt
|
||||
ON attempt.id = (
|
||||
SELECT latest.id
|
||||
FROM ${RUN_ATTEMPT_TABLE} latest
|
||||
WHERE latest.run_id = r.id
|
||||
ORDER BY latest.attempt DESC, latest.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE r.project_id = :projectId
|
||||
AND r.execution_owner = 'legacy'
|
||||
AND r.execution_origin IN (:origins)
|
||||
AND r.created_at_ms >= :windowStartMs
|
||||
AND r.created_at_ms < :windowEndMs
|
||||
${
|
||||
cursor === undefined
|
||||
? ''
|
||||
: `AND (
|
||||
r.created_at_ms > :cursorCreatedAtMs OR
|
||||
(r.created_at_ms = :cursorCreatedAtMs AND r.id > :cursorRunId)
|
||||
)`
|
||||
}
|
||||
ORDER BY r.created_at_ms ASC, r.id ASC
|
||||
LIMIT :fetchLimit`,
|
||||
{
|
||||
replacements: {
|
||||
projectId,
|
||||
origins: [...new Set(origins)],
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
...(cursor === undefined
|
||||
? {}
|
||||
: {
|
||||
cursorCreatedAtMs: cursor.createdAtMs,
|
||||
cursorRunId: cursor.runId,
|
||||
}),
|
||||
fetchLimit: limit + 1,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const truncated = rows.length > limit;
|
||||
const candidates = rows.slice(0, limit).map(candidateFromRow);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
candidates,
|
||||
evidence: [],
|
||||
evidenceTruncated: false,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const evidence = await this.listEvidence({
|
||||
candidates,
|
||||
windowStartMs,
|
||||
observedAtMs,
|
||||
correlationToleranceMs,
|
||||
});
|
||||
const last = candidates[candidates.length - 1];
|
||||
return {
|
||||
candidates,
|
||||
...evidence,
|
||||
truncated,
|
||||
...(truncated
|
||||
? {
|
||||
nextCursor: {
|
||||
createdAtMs: last.createdAtMs,
|
||||
runId: last.runId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async listEvidence({
|
||||
candidates,
|
||||
windowStartMs,
|
||||
observedAtMs,
|
||||
correlationToleranceMs,
|
||||
}: {
|
||||
candidates: readonly LegacyShadowTerminalCandidate[];
|
||||
windowStartMs: number;
|
||||
observedAtMs: number;
|
||||
correlationToleranceMs: number;
|
||||
}): Promise<{
|
||||
evidence: readonly LegacyTerminalEvidence[];
|
||||
evidenceTruncated: boolean;
|
||||
}> {
|
||||
const runIds = candidates.map((candidate) => candidate.runId);
|
||||
const attemptIds = candidates.flatMap((candidate) =>
|
||||
candidate.attempt ? [candidate.attempt.attemptId] : [],
|
||||
);
|
||||
const legacyCronIds = [
|
||||
...new Set(
|
||||
candidates.flatMap((candidate) =>
|
||||
candidate.legacyCronId === undefined ? [] : [candidate.legacyCronId],
|
||||
),
|
||||
),
|
||||
];
|
||||
const clauses = ['run_id IN (:runIds)'];
|
||||
if (attemptIds.length > 0) clauses.push('attempt_id IN (:attemptIds)');
|
||||
if (legacyCronIds.length > 0) {
|
||||
clauses.push(`(
|
||||
cron_id IN (:legacyCronIds)
|
||||
AND started_at >= :startedAfterSeconds
|
||||
AND started_at <= :startedBeforeSeconds
|
||||
)`);
|
||||
}
|
||||
const evidenceLimit = Math.min(
|
||||
MAX_LEGACY_SHADOW_TERMINAL_EVIDENCE_PER_PAGE,
|
||||
candidates.length * 8,
|
||||
);
|
||||
const rows = await this.database.query<EvidenceRow>(
|
||||
`SELECT
|
||||
id AS "instanceId",
|
||||
cron_id AS "legacyCronId",
|
||||
run_id AS "runId",
|
||||
attempt_id AS "attemptId",
|
||||
pid,
|
||||
log_path AS "logPath",
|
||||
started_at AS "startedAt",
|
||||
finished_at AS "finishedAt",
|
||||
status,
|
||||
exit_code AS "exitCode"
|
||||
FROM ${RUNNING_INSTANCE_TABLE}
|
||||
WHERE status IN (1, 2, 3)
|
||||
AND finished_at IS NOT NULL
|
||||
AND (${clauses.join(' OR ')})
|
||||
ORDER BY started_at ASC, id ASC
|
||||
LIMIT :fetchLimit`,
|
||||
{
|
||||
replacements: {
|
||||
runIds,
|
||||
...(attemptIds.length === 0 ? {} : { attemptIds }),
|
||||
...(legacyCronIds.length === 0
|
||||
? {}
|
||||
: {
|
||||
legacyCronIds,
|
||||
startedAfterSeconds: Math.floor(
|
||||
Math.max(0, windowStartMs - correlationToleranceMs) / 1_000,
|
||||
),
|
||||
startedBeforeSeconds: Math.ceil(observedAtMs / 1_000),
|
||||
}),
|
||||
fetchLimit: evidenceLimit + 1,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const evidenceTruncated = rows.length > evidenceLimit;
|
||||
return {
|
||||
evidence: rows.slice(0, evidenceLimit).map((row) => ({
|
||||
instanceId: safeInteger(row.instanceId, 'instanceId'),
|
||||
legacyCronId: safeInteger(row.legacyCronId, 'legacyCronId'),
|
||||
...(row.runId === null ? {} : { runId: row.runId }),
|
||||
...(row.attemptId === null ? {} : { attemptId: row.attemptId }),
|
||||
...(row.pid === null
|
||||
? {}
|
||||
: { pid: optionalPositiveInteger(row.pid, 'pid') }),
|
||||
...(row.logPath === null
|
||||
? {}
|
||||
: { logArtifactId: this.createLogArtifactId(row.logPath) }),
|
||||
startedAtMs: secondsToMilliseconds(row.startedAt, 'startedAt'),
|
||||
finishedAtMs: secondsToMilliseconds(row.finishedAt, 'finishedAt'),
|
||||
outcome: terminalOutcome(safeInteger(row.status, 'status')),
|
||||
...(row.exitCode === null
|
||||
? {}
|
||||
: { exitCode: safeInteger(row.exitCode, 'exitCode') }),
|
||||
})),
|
||||
evidenceTruncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
import {
|
||||
RUN_ATTEMPT_TABLE,
|
||||
RUN_TABLE,
|
||||
} from '../../../migrations/0002-run-schema';
|
||||
import { LOCAL_ARTIFACT_RETENTION_TABLE } from '../../../migrations/0015-local-artifact-retention';
|
||||
import {
|
||||
normalizeLocalArtifactReadMetadata,
|
||||
type LocalArtifactReadMetadata,
|
||||
} from '../../domain/artifactRead';
|
||||
import type { LocalArtifactReadMetadataRepository } from '../../ports/localArtifactReadMetadataRepository';
|
||||
|
||||
interface ArtifactMetadataRow {
|
||||
project_id: string;
|
||||
run_id: string;
|
||||
attempt_id: string;
|
||||
attempt_finished_at_ms: number | string | null;
|
||||
log_artifact_id: string;
|
||||
retention_log_artifact_id: string | null;
|
||||
retention_disposition: string | null;
|
||||
retention_finished_at_ms: number | string | null;
|
||||
retention_eligible_at_ms: number | string | null;
|
||||
retention_bytes_reclaimed: number | string | null;
|
||||
retention_recorded_at_ms: number | string | null;
|
||||
}
|
||||
|
||||
export class CorruptLocalArtifactReadMetadataError extends Error {
|
||||
constructor() {
|
||||
super('Local Artifact read metadata is corrupt or ambiguous');
|
||||
this.name = 'CorruptLocalArtifactReadMetadataError';
|
||||
}
|
||||
}
|
||||
|
||||
function rowToMetadata(
|
||||
row: ArtifactMetadataRow,
|
||||
): Readonly<LocalArtifactReadMetadata> {
|
||||
const retentionValues = [
|
||||
row.retention_log_artifact_id,
|
||||
row.retention_disposition,
|
||||
row.retention_finished_at_ms,
|
||||
row.retention_eligible_at_ms,
|
||||
row.retention_bytes_reclaimed,
|
||||
row.retention_recorded_at_ms,
|
||||
];
|
||||
const hasRetention = retentionValues.every((value) => value !== null);
|
||||
if (!hasRetention && retentionValues.some((value) => value !== null)) {
|
||||
throw new CorruptLocalArtifactReadMetadataError();
|
||||
}
|
||||
if (hasRetention && row.retention_log_artifact_id !== row.log_artifact_id) {
|
||||
throw new CorruptLocalArtifactReadMetadataError();
|
||||
}
|
||||
if (
|
||||
hasRetention &&
|
||||
(row.attempt_finished_at_ms === null ||
|
||||
Number(row.retention_finished_at_ms) !==
|
||||
Number(row.attempt_finished_at_ms))
|
||||
) {
|
||||
throw new CorruptLocalArtifactReadMetadataError();
|
||||
}
|
||||
try {
|
||||
return normalizeLocalArtifactReadMetadata({
|
||||
projectId: row.project_id,
|
||||
runId: row.run_id,
|
||||
attemptId: row.attempt_id,
|
||||
logArtifactId: row.log_artifact_id,
|
||||
...(hasRetention
|
||||
? {
|
||||
retention: {
|
||||
disposition: row.retention_disposition as NonNullable<
|
||||
LocalArtifactReadMetadata['retention']
|
||||
>['disposition'],
|
||||
finishedAtMs: Number(row.retention_finished_at_ms),
|
||||
eligibleAtMs: Number(row.retention_eligible_at_ms),
|
||||
bytesReclaimed: Number(row.retention_bytes_reclaimed),
|
||||
recordedAtMs: Number(row.retention_recorded_at_ms),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CorruptLocalArtifactReadMetadataError) throw error;
|
||||
throw new CorruptLocalArtifactReadMetadataError();
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacySequelizeLocalArtifactReadMetadataRepository
|
||||
implements LocalArtifactReadMetadataRepository
|
||||
{
|
||||
constructor(private readonly database: Sequelize) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Local Artifact read metadata repository is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async find({
|
||||
projectId,
|
||||
runId,
|
||||
logArtifactId,
|
||||
}: Parameters<
|
||||
LocalArtifactReadMetadataRepository['find']
|
||||
>[0]): Promise<Readonly<LocalArtifactReadMetadata> | null> {
|
||||
const rows = await this.database.query<ArtifactMetadataRow>(
|
||||
`SELECT run.project_id,
|
||||
run.id AS run_id,
|
||||
attempt.id AS attempt_id,
|
||||
attempt.finished_at_ms AS attempt_finished_at_ms,
|
||||
attempt.log_artifact_id,
|
||||
retained.log_artifact_id AS retention_log_artifact_id,
|
||||
retained.disposition AS retention_disposition,
|
||||
retained.finished_at_ms AS retention_finished_at_ms,
|
||||
retained.eligible_at_ms AS retention_eligible_at_ms,
|
||||
retained.bytes_reclaimed AS retention_bytes_reclaimed,
|
||||
retained.recorded_at_ms AS retention_recorded_at_ms
|
||||
FROM "${RUN_TABLE}" AS run
|
||||
JOIN "${RUN_ATTEMPT_TABLE}" AS attempt ON attempt.run_id = run.id
|
||||
LEFT JOIN "${LOCAL_ARTIFACT_RETENTION_TABLE}" AS retained
|
||||
ON retained.attempt_id = attempt.id
|
||||
WHERE run.project_id = :projectId
|
||||
AND run.id = :runId
|
||||
AND run.execution_owner = 'runtime'
|
||||
AND attempt.executor_type = 'local_process'
|
||||
AND attempt.log_artifact_id = :logArtifactId
|
||||
AND attempt.log_artifact_id LIKE 'local-%'
|
||||
LIMIT 2`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { projectId, runId, logArtifactId },
|
||||
},
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length !== 1) throw new CorruptLocalArtifactReadMetadataError();
|
||||
return rowToMetadata(rows[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
type ModelStatic,
|
||||
Sequelize,
|
||||
UniqueConstraintError,
|
||||
} from 'sequelize';
|
||||
import { LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE } from '../../../migrations/0016-local-artifact-maintenance-cursor';
|
||||
import {
|
||||
normalizeLocalArtifactRetentionCursor,
|
||||
assertLocalArtifactRetentionTimestamp,
|
||||
} from '../../domain/localArtifactRetention';
|
||||
import {
|
||||
normalizeLocalArtifactRetentionCheckpoint,
|
||||
type LocalArtifactRetentionCheckpoint,
|
||||
} from '../../domain/localArtifactRetentionCheckpoint';
|
||||
import type { LocalArtifactRetentionCheckpointStore } from '../../ports/localArtifactRetentionCheckpointStore';
|
||||
|
||||
const RETENTION_SCOPE = 'retention';
|
||||
|
||||
interface CursorRow {
|
||||
scope: string;
|
||||
cursorFinishedAtMs: number | string | null;
|
||||
cursorAttemptId: string | null;
|
||||
version: number | string;
|
||||
updatedAtMs: number | string;
|
||||
}
|
||||
|
||||
interface CursorInstance extends Model<CursorRow, CursorRow>, CursorRow {}
|
||||
|
||||
function defineCursorModel(database: Sequelize): ModelStatic<CursorInstance> {
|
||||
return database.define<CursorInstance>(
|
||||
'Ql3LocalArtifactMaintenanceCursor',
|
||||
{
|
||||
scope: { type: DataTypes.STRING(32), allowNull: false, primaryKey: true },
|
||||
cursorFinishedAtMs: {
|
||||
field: 'cursor_finished_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
},
|
||||
cursorAttemptId: {
|
||||
field: 'cursor_attempt_id',
|
||||
type: DataTypes.STRING(36),
|
||||
allowNull: true,
|
||||
},
|
||||
version: { type: DataTypes.BIGINT, allowNull: false },
|
||||
updatedAtMs: {
|
||||
field: 'updated_at_ms',
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: LOCAL_ARTIFACT_MAINTENANCE_CURSOR_TABLE,
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function rowToCheckpoint(
|
||||
row: CursorRow,
|
||||
): Readonly<LocalArtifactRetentionCheckpoint> {
|
||||
const finishedAtMs =
|
||||
row.cursorFinishedAtMs === null ? null : Number(row.cursorFinishedAtMs);
|
||||
const attemptId = row.cursorAttemptId;
|
||||
if ((finishedAtMs === null) !== (attemptId === null)) {
|
||||
throw new TypeError('Local Artifact retention cursor row is corrupt');
|
||||
}
|
||||
return normalizeLocalArtifactRetentionCheckpoint({
|
||||
version: Number(row.version),
|
||||
...(finishedAtMs === null || attemptId === null
|
||||
? {}
|
||||
: { cursor: { finishedAtMs, attemptId } }),
|
||||
});
|
||||
}
|
||||
|
||||
export class LegacySequelizeLocalArtifactRetentionCheckpointStore
|
||||
implements LocalArtifactRetentionCheckpointStore
|
||||
{
|
||||
private readonly cursors: ModelStatic<CursorInstance>;
|
||||
|
||||
constructor(database: Sequelize) {
|
||||
if (database.getDialect() !== 'sqlite') {
|
||||
throw new TypeError(
|
||||
'Local Artifact retention checkpoint store is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
||||
);
|
||||
}
|
||||
this.cursors = defineCursorModel(database);
|
||||
}
|
||||
|
||||
async load(): Promise<Readonly<LocalArtifactRetentionCheckpoint>> {
|
||||
const row = await this.cursors.findByPk(RETENTION_SCOPE, { raw: true });
|
||||
return row
|
||||
? rowToCheckpoint(row)
|
||||
: normalizeLocalArtifactRetentionCheckpoint({ version: 0 });
|
||||
}
|
||||
|
||||
async compareAndSet({
|
||||
expectedVersion,
|
||||
cursor,
|
||||
updatedAtMs,
|
||||
}: Parameters<
|
||||
LocalArtifactRetentionCheckpointStore['compareAndSet']
|
||||
>[0]): Promise<boolean> {
|
||||
const checkpoint = normalizeLocalArtifactRetentionCheckpoint({
|
||||
version: expectedVersion,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
assertLocalArtifactRetentionTimestamp('updatedAtMs', updatedAtMs);
|
||||
const next = {
|
||||
scope: RETENTION_SCOPE,
|
||||
cursorFinishedAtMs: checkpoint.cursor?.finishedAtMs ?? null,
|
||||
cursorAttemptId: checkpoint.cursor?.attemptId ?? null,
|
||||
version: checkpoint.version + 1,
|
||||
updatedAtMs,
|
||||
};
|
||||
if (checkpoint.version === 0) {
|
||||
try {
|
||||
await this.cursors.create(next);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const [updated] = await this.cursors.update(next, {
|
||||
where: { scope: RETENTION_SCOPE, version: checkpoint.version },
|
||||
});
|
||||
return updated === 1;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user