Compare commits
385 Commits
main
..
4b2899d13f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2899d13f | |||
| 035e4f3ce8 | |||
| ff03d1939b | |||
| e48d1f3c4d | |||
| ea708f0636 | |||
| 717941ce5e | |||
| 0a41e08160 | |||
| a76044409c | |||
| 7f675410fd | |||
| bd8ee7f88f | |||
| 7ab04aab2d | |||
| 1b528106b7 | |||
| 1bb16cde09 | |||
| 15d7cfa8d9 | |||
| 69edbfb717 | |||
| 6981de1bca | |||
| da86da8cc1 | |||
| 3eeac67634 | |||
| 42312ff76f | |||
| 280ea99628 | |||
| ad61350c2e | |||
| 530cd5f588 | |||
| 0e6d81b109 | |||
| 7ebe8cae22 | |||
| a1e90585b1 | |||
| c28f07901a | |||
| c080692df5 | |||
| 7c2a8f4fac | |||
| 56268d1482 | |||
| c6b1456133 | |||
| 75f76a28bf | |||
| 82deaeec63 | |||
| 5926206e67 | |||
| a43eb88228 | |||
| 8ddcb213a3 | |||
| d9065a737d | |||
| c8c7ee5472 | |||
| f8930fc9b2 | |||
| b2ae9c0901 | |||
| 54865dd258 | |||
| ccada02258 | |||
| c6db8d9c99 | |||
| 1f2ef70813 | |||
| 7f2acdecb9 | |||
| fd11d9ceca | |||
| 23962f98e3 | |||
| f50c8fb327 | |||
| aede79dfb1 | |||
| ef007a4a21 | |||
| ca9b86aada | |||
| 29f971c22d | |||
| 202c886793 | |||
| 796def4cfd | |||
| a3d8040ccc | |||
| 7123a3c734 | |||
| 978ed3a02e | |||
| c4ac6e85c0 | |||
| a61064ba3a | |||
| b2c5e28cba | |||
| 6cec445b4f | |||
| 50b1e5ea13 | |||
| 284ba76b6b | |||
| 851d70ad7a | |||
| d3b7d246da | |||
| 3256203876 | |||
| 2eb2209e40 | |||
| c5cd6c1526 | |||
| 1deb70c926 | |||
| 33a5773415 | |||
| 201fe16aae | |||
| 89761ec1e5 | |||
| 5327b0ef20 | |||
| 17d9c42521 | |||
| 101229d3f4 | |||
| 1661b2b4d0 | |||
| ed962fcab4 | |||
| 704f61cfcc | |||
| 6fe969438e | |||
| 9b3444f2f0 | |||
| 51c70cc1d0 | |||
| 79d878bd92 | |||
| 34d345158f | |||
| 076cead491 | |||
| 46f8e6cbeb | |||
| ffcce360fc | |||
| 18b23fcc0d | |||
| 20598ff5dc | |||
| 82b7b7278e | |||
| 1f0818d660 | |||
| 49c46c0d0b | |||
| 10433d5193 | |||
| a28e6a55cb | |||
| d98aa83b37 | |||
| 6fd62df2a8 | |||
| c7b33930db | |||
| 0f3ad2991e | |||
| 2d43db69c9 | |||
| bf270336d9 | |||
| 420e9416e5 | |||
| fa2a98765d | |||
| 95d9006d70 | |||
| 37b9ab4fbe | |||
| 17eb4f2e5e | |||
| e0864c6c2a | |||
| 4b0545ab23 | |||
| e34df9b96d | |||
| 6c98cc6917 | |||
| 9a83cee84d | |||
| 96f2f20591 | |||
| b938192f0d | |||
| 46585fb445 | |||
| 0e67879051 | |||
| 39cae4a557 | |||
| 8a4deb6249 | |||
| 4256d16426 | |||
| 4203c34dcf | |||
| 558bce385d | |||
| cd0a445614 | |||
| 929c85d5c8 | |||
| c54d234daa | |||
| 3a4dddd071 | |||
| 2f84a38b69 | |||
| a6e896ed89 | |||
| 73de4d4024 | |||
| a8dcd574fb | |||
| 26db91facf | |||
| 62b092cac7 | |||
| f4799d1c7e | |||
| f0d875ffa9 | |||
| 3ec7f34076 | |||
| 73bebee8f6 | |||
| f1de3dc948 | |||
| 4839cae319 | |||
| c597557a5e | |||
| fdad20a8d1 | |||
| 77f83a1dc1 | |||
| 6c8be5c5d8 | |||
| 4fa4bded7c | |||
| 5f68fcbd04 | |||
| 411d662676 | |||
| 66f28defe5 | |||
| 4cd5d5b132 | |||
| 8ac55ebcc2 | |||
| 3d2d97c1e6 | |||
| 9b7bd9ad03 | |||
| 7be6bed347 | |||
| 3b1a7c6993 | |||
| 88033a3b3e | |||
| f3f6a3b702 | |||
| b9ab17d1d3 | |||
| 84555aaf70 | |||
| ba10bf805d | |||
| 37bc3c4e2b | |||
| af511316ba | |||
| 5debf4dd05 | |||
| 899440fd8a | |||
| 153f9cb108 | |||
| 179165838a | |||
| 8d1f5284ba | |||
| dddbbac745 | |||
| c736b4f5a8 | |||
| 03cb109b40 | |||
| edf318ba67 | |||
| e03d4da925 | |||
| 624224f089 | |||
| 075c1762e2 | |||
| 247440a793 | |||
| c940b9b97a | |||
| 934c872753 | |||
| 6a909feb64 | |||
| 57c73b5cb2 | |||
| 5f187a6817 | |||
| dec311b143 | |||
| cdf9cbd8cc | |||
| 77c449f455 | |||
| 825d3cb138 | |||
| 7a9248a0f3 | |||
| 99a5d44fea | |||
| 1cb4cbc960 | |||
| d85f7ddf05 | |||
| 4d7b11ec96 | |||
| b92350a6ac | |||
| 2848d74929 | |||
| 22227ba523 | |||
| c69c327123 | |||
| f30c63146b | |||
| cf3cadce6c | |||
| e628720e7a | |||
| ae1a5c3ab5 | |||
| 53dd4be2dd | |||
| 6250a4c6d1 | |||
| fa75393e5f | |||
| 4dffad603f | |||
| 666ee8ffc2 | |||
| a726e5f172 | |||
| 834031a0fd | |||
| 45de5fb4c8 | |||
| f337bd007e | |||
| d08ee8ce88 | |||
| 2552107ff6 | |||
| 665ad906ec | |||
| 2b81d9fc3b | |||
| 51cf7691be | |||
| 9b6ce337e6 | |||
| 0c17058d21 | |||
| 70e817b44a | |||
| 47b7af784b | |||
| 82c355efc4 | |||
| feb749b7cf | |||
| e60d848293 | |||
| 2770c5935f | |||
| 1a4f0ea6f3 | |||
| 35e9c8c754 | |||
| d8981f8a75 | |||
| 78dc5ebc37 | |||
| 09e6260e32 | |||
| e3cd02aa7e | |||
| 05afde62dc | |||
| a8aaf65918 | |||
| b26ab3d85c | |||
| f1042877b2 | |||
| 01bf5a1188 | |||
| 757c1bbd31 | |||
| 6711214bb1 | |||
| 341496e314 | |||
| da94d308de | |||
| 191e749b14 | |||
| a6388e4353 | |||
| 31fbdfdc02 | |||
| 45ea011b7f | |||
| b5f80c5d2f | |||
| 4953272bcf | |||
| b40daf4f54 | |||
| fdd0a45124 | |||
| bd2c6d4f48 | |||
| bf56494336 | |||
| 8204a59dac | |||
| 7e4f3d142e | |||
| 6a0381b8e5 | |||
| d841cd0dda | |||
| 633af39c82 | |||
| dbc57a0419 | |||
| 7f15b2db9e | |||
| 04ed704e94 | |||
| d7e5ae95d6 | |||
| 420074f96a | |||
| 09604c6481 | |||
| 627023ffcc | |||
| 0bd47d9944 | |||
| d33c52d51d | |||
| 4667c82514 | |||
| e832db2efb | |||
| 2b488749c1 | |||
| 140548bb20 | |||
| 581d50c33c | |||
| 2f79eb8f48 | |||
| 5e20b58766 | |||
| 2d7c43c438 | |||
| 12b43542a9 | |||
| a491c246e0 | |||
| 4113515d0f | |||
| 1871b5e9ee | |||
| 3a7348f685 | |||
| b650941c06 | |||
| ab289165b5 | |||
| 516c3d2814 | |||
| c26e1ac258 | |||
| 94ca6b20d9 | |||
| 89785003ca | |||
| f5a658b4f0 | |||
| 9cee29d110 | |||
| ffa9cc87eb | |||
| 7aabf7077a | |||
| 564334e300 | |||
| ddbe5a0906 | |||
| 7b85227fae | |||
| 36075fe0e4 | |||
| 563e17c3df | |||
| 97ea969a29 | |||
| 86b61523bd | |||
| 4d91ccf48f | |||
| 84f4437ce0 | |||
| 439732a43a | |||
| b7f1aa86ec | |||
| db3a9cdb0a | |||
| 67139a99df | |||
| e007bee23d | |||
| f0b0934721 | |||
| 2db056817d | |||
| cb23f1eb96 | |||
| 3065b3100b | |||
| 44363c0b1c | |||
| 61718bce5a | |||
| dcc4401043 | |||
| 04bfc84c19 | |||
| 96ed1417b9 | |||
| 478100ac5d | |||
| 8adb1dc6f7 | |||
| a893ed01f5 | |||
| fd30e8f626 | |||
| 39ab77833e | |||
| 1d14bdca6b | |||
| ab2e603997 | |||
| 91854a4153 | |||
| 0cec8cc826 | |||
| e4d2f6adc9 | |||
| 40ef8d9cf8 | |||
| 354d88618c | |||
| a489e651c2 | |||
| e8a4a1138b | |||
| 85395b10f8 | |||
| 896078d99e | |||
| f45ace378a | |||
| 23ca2cda59 | |||
| 4721bc1948 | |||
| 7070231c3e | |||
| f33aa29c88 | |||
| be9ee95cbe | |||
| 0a1680f24e | |||
| 587584a8bf | |||
| 9fb69c1571 | |||
| 873683cac6 | |||
| bf2d61f55b | |||
| 7b7699cf8b | |||
| d757adb192 | |||
| d3ab565fff | |||
| c78b949050 | |||
| 2219d1249b | |||
| df2974bafa | |||
| 8820984229 | |||
| 9767d12966 | |||
| 944490d5d0 | |||
| 4dd09e1323 | |||
| e342bac29a | |||
| efbe445e6f | |||
| 26335e8d9b | |||
| 6821d0c64a | |||
| 1506ab9dfc | |||
| 9c6aafe940 | |||
| f0c9e0d14c | |||
| 3a17f9151f | |||
| ff3a44eef6 | |||
| aab06faa8a | |||
| 9d1f5347c2 | |||
| 8c5d585b7f | |||
| bc5b55d0ab | |||
| f5a957585a | |||
| c24c8cd0c4 | |||
| 6ad8676cf1 | |||
| 9e157be116 | |||
| c1447c8365 | |||
| f8b79906ed | |||
| 26a1397699 | |||
| 7baef66180 | |||
| 2d3abb9f3d | |||
| 1a6d7c5ddc | |||
| 8005510ad2 | |||
| ada2436e54 | |||
| 1ea75eb824 | |||
| 6296b04105 | |||
| 752288ca70 | |||
| a776ae4a73 | |||
| 50c3301606 | |||
| 0dc1d30962 | |||
| 85ffda67d3 | |||
| 39c88456b4 | |||
| aef87f582f | |||
| 0f72f2a6cb | |||
| 8a7e8dbd64 | |||
| 649a2ede0d | |||
| ccc4adb173 | |||
| d37d40a975 | |||
| b32b702d67 | |||
| 87639048ce | |||
| ef128ac25a | |||
| 7901a0365a | |||
| ceb37ad0d3 | |||
| 0021ac31d8 | |||
| d61758e0b4 | |||
| 4d2b566de2 | |||
| e9f17841e4 | |||
| 23a4601c30 | |||
| 08f354d469 | |||
| fb302b2a2c | |||
| 180e89effa |
@@ -1,2 +0,0 @@
|
|||||||
[build]
|
|
||||||
jobs = 2
|
|
||||||
+21
-56
@@ -1,71 +1,36 @@
|
|||||||
# Deployment-only image and publication settings.
|
|
||||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
|
||||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
|
||||||
CRANK_UI_IMAGE=crank/ui:dev
|
|
||||||
CRANK_PUBLISH_BIND=127.0.0.1
|
|
||||||
|
|
||||||
# BEGIN GENERATED CRANK RUNTIME CONFIG
|
|
||||||
CRANK_DATABASE_URL=
|
|
||||||
POSTGRES_HOST=postgres
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=crank
|
POSTGRES_DB=crank
|
||||||
POSTGRES_USER=crank
|
POSTGRES_USER=crank
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_MAX_CONNECTIONS=20
|
POSTGRES_MAX_CONNECTIONS=20
|
||||||
POSTGRES_MIN_CONNECTIONS=2
|
POSTGRES_MIN_CONNECTIONS=2
|
||||||
POSTGRES_ACQUIRE_TIMEOUT_MS=5000
|
POSTGRES_ACQUIRE_TIMEOUT_MS=5000
|
||||||
POSTGRES_IDLE_TIMEOUT_MS=600000
|
POSTGRES_IDLE_TIMEOUT_MS=600000
|
||||||
POSTGRES_MAX_LIFETIME_MS=1800000
|
POSTGRES_MAX_LIFETIME_MS=1800000
|
||||||
CRANK_MASTER_KEY=
|
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
||||||
CRANK_BASE_URL=http://localhost:3000
|
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
||||||
CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
CRANK_UI_IMAGE=crank/ui:dev
|
||||||
CRANK_CACHE_BACKEND=memory
|
|
||||||
CRANK_CACHE_URL=
|
|
||||||
CRANK_OUTBOUND_ALLOWED_HOSTS=
|
|
||||||
CRANK_OUTBOUND_DENIED_HOSTS=
|
|
||||||
CRANK_OUTBOUND_MAX_REQUEST_BYTES=4194304
|
|
||||||
CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES=
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DEPTH=8
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DOCUMENTS=32
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_FETCH_BYTES=262144
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_FETCH_TIMEOUT_MS=10000
|
|
||||||
CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_EXPANDED_NODES=10000
|
|
||||||
CRANK_ENVIRONMENT=development
|
|
||||||
CRANK_LOG_LEVEL=
|
|
||||||
CRANK_SENTRY_DSN=
|
|
||||||
CRANK_METRICS_ENABLED=true
|
|
||||||
CRANK_METRICS_BEARER_TOKEN=
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
|
||||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
|
|
||||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
||||||
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=
|
|
||||||
OTEL_EXPORTER_OTLP_TIMEOUT=10000
|
|
||||||
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=
|
|
||||||
OTEL_EXPORTER_OTLP_HEADERS=
|
|
||||||
OTEL_EXPORTER_OTLP_TRACES_HEADERS=
|
|
||||||
OTEL_BSP_MAX_QUEUE_SIZE=2048
|
|
||||||
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
|
|
||||||
OTEL_BSP_SCHEDULE_DELAY=5000
|
|
||||||
OTEL_BSP_EXPORT_TIMEOUT=30000
|
|
||||||
CRANK_ADMIN_BIND=0.0.0.0:3001
|
|
||||||
CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464
|
|
||||||
CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
||||||
|
CRANK_PUBLISH_BIND=127.0.0.1
|
||||||
|
CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||||
CRANK_ADMIN_RATE_LIMIT_RPS=30
|
CRANK_ADMIN_RATE_LIMIT_RPS=30
|
||||||
CRANK_ADMIN_RATE_LIMIT_BURST=60
|
CRANK_ADMIN_RATE_LIMIT_BURST=60
|
||||||
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
|
|
||||||
CRANK_SESSION_SECRET=
|
|
||||||
CRANK_PASSWORD_PEPPER=
|
|
||||||
CRANK_SESSION_TTL_HOURS=24
|
|
||||||
CRANK_TRUSTED_PROXY_IPS=
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
|
||||||
CRANK_DEMO_SEED=false
|
|
||||||
CRANK_MCP_BIND=0.0.0.0:3002
|
CRANK_MCP_BIND=0.0.0.0:3002
|
||||||
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
|
|
||||||
CRANK_MCP_REFRESH_MS=5000
|
CRANK_MCP_REFRESH_MS=5000
|
||||||
CRANK_MCP_RATE_LIMIT_RPS=60
|
CRANK_MCP_RATE_LIMIT_RPS=60
|
||||||
CRANK_MCP_RATE_LIMIT_BURST=120
|
CRANK_MCP_RATE_LIMIT_BURST=120
|
||||||
|
CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
||||||
|
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||||
# END GENERATED CRANK RUNTIME CONFIG
|
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||||
|
CRANK_LOG_LEVEL=info
|
||||||
|
CRANK_MASTER_KEY=change-me-master-key
|
||||||
|
CRANK_SESSION_SECRET=change-me-session-secret
|
||||||
|
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||||
|
CRANK_SESSION_TTL_HOURS=24
|
||||||
|
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||||
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||||
|
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||||
|
CRANK_DEMO_SEED=false
|
||||||
|
CRANK_BASE_URL=https://crank.example.com
|
||||||
|
|||||||
+26
-481
@@ -7,118 +7,52 @@ on:
|
|||||||
- main
|
- main
|
||||||
- "feat/**"
|
- "feat/**"
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_BUILD_JOBS: "2"
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
RUST_TEST_THREADS: "2"
|
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Rust Checks
|
name: Rust Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
crank-rust-postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: crank_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres -d crank_test"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgres://postgres:postgres@crank-rust-postgres:5432/crank_test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
|
||||||
if [ -z "$toolchain" ]; then
|
|
||||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
|
||||||
rustup default "$toolchain"
|
|
||||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
|
||||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
|
||||||
toolchain_bin="$toolchain_dir/bin"
|
|
||||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
|
||||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
|
||||||
"$toolchain_bin/rustc" --version
|
|
||||||
"$toolchain_bin/cargo" --version
|
|
||||||
"$toolchain_bin/rustfmt" --version
|
|
||||||
"$toolchain_bin/cargo-clippy" --version
|
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
rustfmt --version
|
rustfmt --version
|
||||||
cargo clippy --version
|
cargo clippy --version
|
||||||
docker --version
|
docker --version
|
||||||
docker info
|
|
||||||
|
|
||||||
- name: Install dependency policy tool
|
|
||||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
|
||||||
|
|
||||||
- name: Run tooling unit tests
|
|
||||||
run: python3 -m unittest discover -s tests/unit
|
|
||||||
|
|
||||||
- name: Check typed runtime configuration contract
|
|
||||||
run: |
|
|
||||||
cargo run -p crank-config --bin crank-config-contract -- --check
|
|
||||||
python3 scripts/check-runtime-config.py --root .
|
|
||||||
python3 scripts/check-config-boundaries.py --root .
|
|
||||||
|
|
||||||
- name: Check canonical migration contract
|
|
||||||
run: cargo run -p admin-api --bin crank-migrate -- plan --check
|
|
||||||
|
|
||||||
- name: Check typed metrics contract
|
|
||||||
run: |
|
|
||||||
cargo run -p crank-metrics --bin crank-metrics-contract -- --check
|
|
||||||
python3 scripts/check-metrics-boundaries.py --root .
|
|
||||||
|
|
||||||
- name: Check Capability Inventory
|
|
||||||
run: |
|
|
||||||
required_args=""
|
|
||||||
for number in $(seq 1 54); do
|
|
||||||
required_args="$required_args --required-fr FR-$number"
|
|
||||||
done
|
|
||||||
python3 scripts/validate-capability-inventory.py \
|
|
||||||
--root . \
|
|
||||||
--inventory docs/capability-inventory.json \
|
|
||||||
--schema docs/schemas/capability-inventory.schema.json \
|
|
||||||
$required_args
|
|
||||||
|
|
||||||
- name: Check Capability Baseline
|
|
||||||
run: |
|
|
||||||
python3 scripts/validate-capability-baseline.py \
|
|
||||||
--root . \
|
|
||||||
--manifest docs/capability-baseline/manifest.json \
|
|
||||||
--schema docs/schemas/capability-baseline.schema.json
|
|
||||||
|
|
||||||
- name: Check Community scope
|
|
||||||
run: scripts/check-community-scope.sh
|
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cargo fmt --all --check
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
- name: Check Rust code health
|
- name: Check workspace
|
||||||
run: scripts/check-rust-code-health.sh
|
run: cargo check --workspace
|
||||||
|
|
||||||
- name: Check dependency licenses and advisories
|
|
||||||
run: cargo deny --locked check advisories bans licenses sources
|
|
||||||
|
|
||||||
- name: Check Rust boundaries
|
|
||||||
run: scripts/check-rust-boundaries.sh
|
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS" -- --test-threads=1
|
run: cargo test --workspace --all-targets
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
name: UI Checks
|
name: UI Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: rust
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -134,18 +68,16 @@ jobs:
|
|||||||
working-directory: apps/ui
|
working-directory: apps/ui
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Audit UI dependencies
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: npm audit --audit-level=high
|
|
||||||
|
|
||||||
- name: Build UI bundle
|
- name: Build UI bundle
|
||||||
working-directory: apps/ui
|
working-directory: apps/ui
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build UI image
|
||||||
|
run: docker build -f apps/ui/Dockerfile .
|
||||||
|
|
||||||
frontend-e2e:
|
frontend-e2e:
|
||||||
name: Frontend E2E
|
name: Frontend E2E
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: ui
|
|
||||||
services:
|
services:
|
||||||
crank-e2e-postgres:
|
crank-e2e-postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -170,29 +102,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n1)"
|
|
||||||
if [ -z "$toolchain" ]; then
|
|
||||||
echo "Unable to read Rust toolchain channel from rust-toolchain.toml" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rustup toolchain install "$toolchain" --profile minimal --component clippy --component rustfmt
|
|
||||||
rustup default "$toolchain"
|
|
||||||
host="$(rustc -vV | sed -n 's/^host: //p')"
|
|
||||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/${toolchain}-${host}"
|
|
||||||
toolchain_bin="$toolchain_dir/bin"
|
|
||||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
|
||||||
echo "Rust $toolchain was not installed at $toolchain_dir." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
|
||||||
"$toolchain_bin/rustc" --version
|
|
||||||
"$toolchain_bin/cargo" --version
|
|
||||||
"$toolchain_bin/rustfmt" --version
|
|
||||||
"$toolchain_bin/cargo-clippy" --version
|
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
rustc --version
|
||||||
@@ -210,35 +119,11 @@ jobs:
|
|||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Prebuild e2e services
|
- name: Prebuild e2e services
|
||||||
run: cargo build -p admin-api -p mcp-server --jobs "$CARGO_BUILD_JOBS"
|
run: cargo build -p admin-api -p mcp-server
|
||||||
|
|
||||||
- name: Run Playwright e2e
|
- name: Run Playwright e2e
|
||||||
working-directory: apps/ui
|
working-directory: apps/ui
|
||||||
run: |
|
run: npx playwright test
|
||||||
mkdir -p ../../.tmp
|
|
||||||
rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json
|
|
||||||
PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npx playwright test
|
|
||||||
|
|
||||||
- name: Collect sanitized OpenAPI UI evidence
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: |
|
|
||||||
trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT
|
|
||||||
python3 ../../scripts/collect-capability-baseline.py playwright \
|
|
||||||
--report ../../.tmp/openapi-playwright.json \
|
|
||||||
--output ../../.tmp/openapi-ui-evidence.json \
|
|
||||||
--source-revision "$(git -C ../.. rev-parse HEAD)" \
|
|
||||||
--environment-class ci \
|
|
||||||
--flow-id openapi-upload-ui \
|
|
||||||
--required-test 'operations page imports OpenAPI methods as drafts' \
|
|
||||||
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
|
|
||||||
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
|
|
||||||
--required-test 'OpenAPI apply preserves job authority after language or workspace changes' \
|
|
||||||
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
|
|
||||||
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
|
|
||||||
python3 ../../scripts/validate-capability-run.py \
|
|
||||||
--schema ../../docs/schemas/capability-baseline.schema.json \
|
|
||||||
--candidate ../../.tmp/openapi-ui-evidence.json \
|
|
||||||
--require-accepted
|
|
||||||
|
|
||||||
- name: Show Playwright stack logs
|
- name: Show Playwright stack logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -246,352 +131,12 @@ jobs:
|
|||||||
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
|
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
|
||||||
|
|
||||||
deployment:
|
deployment:
|
||||||
name: Community Image Smoke
|
name: Deployment Manifests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
|
||||||
- rust
|
|
||||||
- ui
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Validate Community deployment manifests
|
- name: Validate Community deployment manifest
|
||||||
run: |
|
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||||
docker compose -f docker-compose.yml --env-file .env.example config -q
|
|
||||||
docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml --env-file deploy/community/.env.images.example --profile local-db config -q
|
|
||||||
|
|
||||||
- name: Build Community images
|
|
||||||
run: |
|
|
||||||
docker build -f apps/admin-api/Dockerfile -t crank/admin-api:ci .
|
|
||||||
docker build -f apps/mcp-server/Dockerfile -t crank/mcp-server:ci .
|
|
||||||
docker build -f apps/ui/Dockerfile -t crank/ui:ci .
|
|
||||||
|
|
||||||
- name: Start Community image stack
|
|
||||||
run: |
|
|
||||||
mkdir -p .tmp
|
|
||||||
cat > .tmp/community-smoke.env <<'EOF'
|
|
||||||
COMPOSE_PROJECT_NAME=crank-ci-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
POSTGRES_HOST=postgres
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_PUBLISH_PORT=0
|
|
||||||
POSTGRES_DB=crank
|
|
||||||
POSTGRES_USER=crank
|
|
||||||
POSTGRES_PASSWORD=crank-ci-password
|
|
||||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:ci
|
|
||||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:ci
|
|
||||||
CRANK_UI_IMAGE=crank/ui:ci
|
|
||||||
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
|
||||||
CRANK_SESSION_SECRET=ci-session-secret
|
|
||||||
CRANK_PASSWORD_PEPPER=ci-password-pepper
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
|
|
||||||
CRANK_BASE_URL=http://127.0.0.1
|
|
||||||
CRANK_ENVIRONMENT=ci
|
|
||||||
CRANK_OUTBOUND_ALLOWED_HOSTS=admin-api
|
|
||||||
CRANK_PUBLISH_BIND=127.0.0.1
|
|
||||||
CRANK_ADMIN_PUBLISH_PORT=0
|
|
||||||
CRANK_MCP_PUBLISH_PORT=0
|
|
||||||
CRANK_UI_PUBLISH_PORT=0
|
|
||||||
CRANK_DEMO_SEED=true
|
|
||||||
EOF
|
|
||||||
compose=(docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/community-smoke.env --profile local-db)
|
|
||||||
"${compose[@]}" up -d --wait postgres
|
|
||||||
"${compose[@]}" run --rm migrate
|
|
||||||
bootstrap_json="$("${compose[@]}" run --rm --no-deps \
|
|
||||||
--entrypoint crank-migrate migrate admin-auth bootstrap-create \
|
|
||||||
--email owner@crank.test --display-name 'CI Owner')"
|
|
||||||
bootstrap_token="$(python3 -c \
|
|
||||||
'import json,sys; print(json.loads(sys.stdin.read())["bootstrap_token"])' \
|
|
||||||
<<<"$bootstrap_json")"
|
|
||||||
install -d -m 700 .tmp/community-bootstrap
|
|
||||||
printf '%s' "$bootstrap_token" > .tmp/community-bootstrap/token
|
|
||||||
printf '%s' 'ci-admin-password' > .tmp/community-bootstrap/password
|
|
||||||
printf '%s' 'ci-password-pepper' > .tmp/community-bootstrap/password-pepper
|
|
||||||
chmod 600 .tmp/community-bootstrap/token \
|
|
||||||
.tmp/community-bootstrap/password \
|
|
||||||
.tmp/community-bootstrap/password-pepper
|
|
||||||
"${compose[@]}" run --rm --no-deps \
|
|
||||||
-v "$PWD/.tmp/community-bootstrap:/run/bootstrap:ro" \
|
|
||||||
--entrypoint crank-migrate migrate admin-auth bootstrap-complete \
|
|
||||||
--token-file /run/bootstrap/token \
|
|
||||||
--password-file /run/bootstrap/password \
|
|
||||||
--password-pepper-file /run/bootstrap/password-pepper
|
|
||||||
rm -f .tmp/community-bootstrap/token \
|
|
||||||
.tmp/community-bootstrap/password \
|
|
||||||
.tmp/community-bootstrap/password-pepper
|
|
||||||
rmdir .tmp/community-bootstrap
|
|
||||||
"${compose[@]}" up -d --wait
|
|
||||||
|
|
||||||
- name: Run authenticated Community image smoke
|
|
||||||
env:
|
|
||||||
CRANK_STAGING_ADMIN_EMAIL: owner@crank.test
|
|
||||||
CRANK_STAGING_ADMIN_PASSWORD: ci-admin-password
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
project_name="$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .tmp/community-smoke.env)"
|
|
||||||
docker run --rm --network "${project_name}_default" \
|
|
||||||
-e CRANK_STAGING_ADMIN_EMAIL \
|
|
||||||
-e CRANK_STAGING_ADMIN_PASSWORD \
|
|
||||||
-i python:3.13-alpine \
|
|
||||||
python - http://ui:3000 < scripts/authenticated-product-smoke.py
|
|
||||||
|
|
||||||
- name: Show Community image logs
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/community-smoke.env --profile local-db ps || true
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/community-smoke.env --profile local-db logs --no-color || true
|
|
||||||
|
|
||||||
- name: Stop Community image stack
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/community-smoke.env --profile local-db down -v --remove-orphans || true
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- rust
|
|
||||||
- ui
|
|
||||||
- frontend-e2e
|
|
||||||
- deployment
|
|
||||||
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
|
|
||||||
env:
|
|
||||||
REGISTRY: git.itexp.me
|
|
||||||
IMAGE_TAG: ${{ gitea.sha }}
|
|
||||||
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
|
||||||
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
|
||||||
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
|
||||||
OPENBAO_ENV_FILE: .openbao-env
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
|
||||||
run: |
|
|
||||||
docker --version
|
|
||||||
command -v bao
|
|
||||||
bao version
|
|
||||||
|
|
||||||
- name: Load deployment secrets from OpenBao
|
|
||||||
env:
|
|
||||||
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
|
||||||
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
|
||||||
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
|
||||||
OPENBAO_APP: crank
|
|
||||||
run: scripts/load-openbao-env.sh
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
|
||||||
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
|
||||||
|
|
||||||
- name: Build and push images
|
|
||||||
run: |
|
|
||||||
docker build -f apps/admin-api/Dockerfile \
|
|
||||||
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
-t '${{ env.ADMIN_API_IMAGE }}:main' \
|
|
||||||
.
|
|
||||||
docker build -f apps/mcp-server/Dockerfile \
|
|
||||||
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
|
|
||||||
.
|
|
||||||
docker build -f apps/ui/Dockerfile \
|
|
||||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
-t '${{ env.UI_IMAGE }}:main' \
|
|
||||||
.
|
|
||||||
scripts/scan-images.sh \
|
|
||||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
|
||||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
|
||||||
docker push '${{ env.ADMIN_API_IMAGE }}:main'
|
|
||||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
|
||||||
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
|
|
||||||
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
|
||||||
docker push '${{ env.UI_IMAGE }}:main'
|
|
||||||
|
|
||||||
- name: Configure SSH key
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
- name: Configure known hosts
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
else
|
|
||||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Sync deployment files to server
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
||||||
"mkdir -p '$DEPLOY_PATH' && \
|
|
||||||
if [ -f '$DEPLOY_PATH/docker-compose.yml' ]; then \
|
|
||||||
cp '$DEPLOY_PATH/docker-compose.yml' '$DEPLOY_PATH/docker-compose.previous.yml'; \
|
|
||||||
fi"
|
|
||||||
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
|
||||||
rsync -az -e "ssh -p $DEPLOY_PORT" scripts/deploy-community.sh \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/deploy-community.sh"
|
|
||||||
|
|
||||||
- name: Write environment file
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
tmp_env="$(mktemp)"
|
|
||||||
append_if_set() {
|
|
||||||
if [ -n "$2" ]; then
|
|
||||||
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
: > "$tmp_env"
|
|
||||||
append_if_set POSTGRES_DB "$POSTGRES_DB"
|
|
||||||
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
|
||||||
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
|
||||||
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
|
||||||
append_if_set POSTGRES_MAX_CONNECTIONS "${POSTGRES_MAX_CONNECTIONS:-}"
|
|
||||||
append_if_set POSTGRES_MIN_CONNECTIONS "${POSTGRES_MIN_CONNECTIONS:-}"
|
|
||||||
append_if_set POSTGRES_ACQUIRE_TIMEOUT_MS "${POSTGRES_ACQUIRE_TIMEOUT_MS:-}"
|
|
||||||
append_if_set POSTGRES_IDLE_TIMEOUT_MS "${POSTGRES_IDLE_TIMEOUT_MS:-}"
|
|
||||||
append_if_set POSTGRES_MAX_LIFETIME_MS "${POSTGRES_MAX_LIFETIME_MS:-}"
|
|
||||||
if [ -n "${POSTGRES_PORT:-}" ]; then
|
|
||||||
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
|
||||||
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
|
|
||||||
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
|
|
||||||
fi
|
|
||||||
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
|
|
||||||
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
|
|
||||||
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
|
||||||
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
|
||||||
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
|
||||||
append_if_set CRANK_ADMIN_RATE_LIMIT_RPS "${CRANK_ADMIN_RATE_LIMIT_RPS:-}"
|
|
||||||
append_if_set CRANK_ADMIN_RATE_LIMIT_BURST "${CRANK_ADMIN_RATE_LIMIT_BURST:-}"
|
|
||||||
append_if_set CRANK_MCP_RATE_LIMIT_RPS "${CRANK_MCP_RATE_LIMIT_RPS:-}"
|
|
||||||
append_if_set CRANK_MCP_RATE_LIMIT_BURST "${CRANK_MCP_RATE_LIMIT_BURST:-}"
|
|
||||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_UNARY "${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-}"
|
|
||||||
append_if_set CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS "${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-}"
|
|
||||||
append_if_set CRANK_OUTBOUND_ALLOWED_HOSTS "${CRANK_OUTBOUND_ALLOWED_HOSTS:-}"
|
|
||||||
append_if_set CRANK_OUTBOUND_DENIED_HOSTS "${CRANK_OUTBOUND_DENIED_HOSTS:-}"
|
|
||||||
append_if_set CRANK_OUTBOUND_MAX_RESPONSE_BYTES "${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-}"
|
|
||||||
append_if_set CRANK_ENVIRONMENT "${CRANK_ENVIRONMENT:-production}"
|
|
||||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
|
||||||
append_if_set CRANK_SENTRY_DSN "${CRANK_SENTRY_DSN:-}"
|
|
||||||
append_if_set CRANK_METRICS_ENABLED "${CRANK_METRICS_ENABLED:-}"
|
|
||||||
append_if_set CRANK_ADMIN_METRICS_BIND "${CRANK_ADMIN_METRICS_BIND:-}"
|
|
||||||
append_if_set CRANK_MCP_METRICS_BIND "${CRANK_MCP_METRICS_BIND:-}"
|
|
||||||
append_if_set CRANK_METRICS_BEARER_TOKEN "${CRANK_METRICS_BEARER_TOKEN:-}"
|
|
||||||
append_if_set CRANK_INVOCATION_LOG_RETENTION_DAYS "${CRANK_INVOCATION_LOG_RETENTION_DAYS:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_ENDPOINT "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT "${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_PROTOCOL "${OTEL_EXPORTER_OTLP_PROTOCOL:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_PROTOCOL "${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_TIMEOUT "${OTEL_EXPORTER_OTLP_TIMEOUT:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_TIMEOUT "${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_HEADERS "${OTEL_EXPORTER_OTLP_HEADERS:-}"
|
|
||||||
append_if_set OTEL_EXPORTER_OTLP_TRACES_HEADERS "${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-}"
|
|
||||||
append_if_set OTEL_BSP_MAX_QUEUE_SIZE "${OTEL_BSP_MAX_QUEUE_SIZE:-}"
|
|
||||||
append_if_set OTEL_BSP_MAX_EXPORT_BATCH_SIZE "${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-}"
|
|
||||||
append_if_set OTEL_BSP_SCHEDULE_DELAY "${OTEL_BSP_SCHEDULE_DELAY:-}"
|
|
||||||
append_if_set OTEL_BSP_EXPORT_TIMEOUT "${OTEL_BSP_EXPORT_TIMEOUT:-}"
|
|
||||||
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
|
||||||
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
|
||||||
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
|
|
||||||
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
|
|
||||||
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
|
|
||||||
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
|
|
||||||
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
|
|
||||||
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
|
|
||||||
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
|
|
||||||
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
|
|
||||||
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
|
|
||||||
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
|
|
||||||
{
|
|
||||||
printf 'COMPOSE_PROJECT_NAME=community\n'
|
|
||||||
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
|
||||||
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
|
||||||
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
|
||||||
} >> "$tmp_env"
|
|
||||||
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
||||||
"mkdir -p '$DEPLOY_PATH' && \
|
|
||||||
if [ -f '$DEPLOY_PATH/.env' ]; then \
|
|
||||||
cp '$DEPLOY_PATH/.env' '$DEPLOY_PATH/.env.previous'; \
|
|
||||||
fi && cat > '$DEPLOY_PATH/.env'"
|
|
||||||
rm -f "$tmp_env"
|
|
||||||
|
|
||||||
- name: Validate required environment variables
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
|
||||||
set -e
|
|
||||||
cd '$DEPLOY_PATH'
|
|
||||||
required_vars='
|
|
||||||
POSTGRES_HOST
|
|
||||||
POSTGRES_PORT
|
|
||||||
POSTGRES_DB
|
|
||||||
POSTGRES_USER
|
|
||||||
POSTGRES_PASSWORD
|
|
||||||
CRANK_MASTER_KEY
|
|
||||||
CRANK_SESSION_SECRET
|
|
||||||
CRANK_PASSWORD_PEPPER
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD
|
|
||||||
CRANK_BASE_URL
|
|
||||||
'
|
|
||||||
for var in \$required_vars; do
|
|
||||||
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
|
|
||||||
if [ -z \"\$value\" ]; then
|
|
||||||
echo \"missing required env: \$var\" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Deploy with Docker Compose
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | ssh -p "$DEPLOY_PORT" \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
|
||||||
"docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin"
|
|
||||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
||||||
"chmod 700 '$DEPLOY_PATH/deploy-community.sh' && \
|
|
||||||
'$DEPLOY_PATH/deploy-community.sh' '$DEPLOY_PATH'"
|
|
||||||
|
|
||||||
- name: Verify health endpoints
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
|
||||||
set -e
|
|
||||||
cd '$DEPLOY_PATH'
|
|
||||||
for attempt in \$(seq 1 30); do
|
|
||||||
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
|
|
||||||
&& curl --fail --silent http://127.0.0.1:3001/ready >/dev/null \
|
|
||||||
&& curl --fail --silent http://127.0.0.1:3002/ready >/dev/null; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo 'deployment health verification failed' >&2
|
|
||||||
docker compose ps >&2
|
|
||||||
exit 1
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Run authenticated product smoke
|
|
||||||
run: |
|
|
||||||
. "$OPENBAO_ENV_FILE"
|
|
||||||
CRANK_STAGING_ADMIN_EMAIL="$CRANK_BOOTSTRAP_ADMIN_EMAIL" \
|
|
||||||
CRANK_STAGING_ADMIN_PASSWORD="$CRANK_BOOTSTRAP_ADMIN_PASSWORD" \
|
|
||||||
scripts/authenticated-product-smoke.sh "$CRANK_BASE_URL"
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.itexp.me
|
||||||
|
IMAGE_TAG: ${{ gitea.sha }}
|
||||||
|
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
||||||
|
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
||||||
|
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
||||||
|
OPENBAO_ENV_FILE: .openbao-env
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Verify runner toolchain
|
||||||
|
run: |
|
||||||
|
docker --version
|
||||||
|
command -v bao
|
||||||
|
bao version
|
||||||
|
|
||||||
|
- name: Load deployment secrets from OpenBao
|
||||||
|
env:
|
||||||
|
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
||||||
|
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
||||||
|
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
||||||
|
OPENBAO_APP: crank
|
||||||
|
run: scripts/load-openbao-env.sh
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
||||||
|
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push images
|
||||||
|
run: |
|
||||||
|
docker build -f apps/admin-api/Dockerfile \
|
||||||
|
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||||
|
-t '${{ env.ADMIN_API_IMAGE }}:main' \
|
||||||
|
.
|
||||||
|
docker build -f apps/mcp-server/Dockerfile \
|
||||||
|
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||||
|
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
|
||||||
|
.
|
||||||
|
docker build -f apps/ui/Dockerfile \
|
||||||
|
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||||
|
-t '${{ env.UI_IMAGE }}:main' \
|
||||||
|
.
|
||||||
|
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||||
|
docker push '${{ env.ADMIN_API_IMAGE }}:main'
|
||||||
|
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||||
|
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
|
||||||
|
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||||
|
docker push '${{ env.UI_IMAGE }}:main'
|
||||||
|
|
||||||
|
- name: Configure SSH key
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
- name: Configure known hosts
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||||
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
else
|
||||||
|
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
||||||
|
fi
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Sync deployment files to server
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"mkdir -p '$DEPLOY_PATH'"
|
||||||
|
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||||
|
|
||||||
|
- name: Write environment file
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
tmp_env="$(mktemp)"
|
||||||
|
append_if_set() {
|
||||||
|
if [ -n "$2" ]; then
|
||||||
|
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
: > "$tmp_env"
|
||||||
|
append_if_set POSTGRES_DB "$POSTGRES_DB"
|
||||||
|
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
||||||
|
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||||
|
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
||||||
|
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
||||||
|
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
|
||||||
|
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
|
||||||
|
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
||||||
|
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
||||||
|
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
||||||
|
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||||
|
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
||||||
|
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
||||||
|
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
|
||||||
|
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
|
||||||
|
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
|
||||||
|
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
|
||||||
|
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
|
||||||
|
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
|
||||||
|
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
|
||||||
|
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
|
||||||
|
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
|
||||||
|
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
|
||||||
|
{
|
||||||
|
printf 'COMPOSE_PROJECT_NAME=community\n'
|
||||||
|
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||||
|
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||||
|
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||||
|
} >> "$tmp_env"
|
||||||
|
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'"
|
||||||
|
rm -f "$tmp_env"
|
||||||
|
|
||||||
|
- name: Validate required environment variables
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||||
|
set -e
|
||||||
|
cd '$DEPLOY_PATH'
|
||||||
|
required_vars='
|
||||||
|
POSTGRES_HOST
|
||||||
|
POSTGRES_PORT
|
||||||
|
POSTGRES_DB
|
||||||
|
POSTGRES_USER
|
||||||
|
POSTGRES_PASSWORD
|
||||||
|
CRANK_MASTER_KEY
|
||||||
|
CRANK_SESSION_SECRET
|
||||||
|
CRANK_PASSWORD_PEPPER
|
||||||
|
CRANK_BOOTSTRAP_ADMIN_EMAIL
|
||||||
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD
|
||||||
|
CRANK_BASE_URL
|
||||||
|
'
|
||||||
|
for var in \$required_vars; do
|
||||||
|
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
|
||||||
|
if [ -z \"\$value\" ]; then
|
||||||
|
echo \"missing required env: \$var\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Deploy with Docker Compose
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||||
|
set -e
|
||||||
|
cd '$DEPLOY_PATH'
|
||||||
|
compose_profiles=''
|
||||||
|
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true)
|
||||||
|
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then
|
||||||
|
compose_profiles='--profile cache'
|
||||||
|
fi
|
||||||
|
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
|
||||||
|
docker compose \$compose_profiles config -q
|
||||||
|
docker compose \$compose_profiles pull
|
||||||
|
docker compose \$compose_profiles down --remove-orphans
|
||||||
|
for container in \
|
||||||
|
crank-ui-1 \
|
||||||
|
crank-admin-api-1 \
|
||||||
|
crank-mcp-server-1 \
|
||||||
|
crank-postgres-1 \
|
||||||
|
crank-valkey-1 \
|
||||||
|
crank-community-ui-1 \
|
||||||
|
crank-community-admin-api-1 \
|
||||||
|
crank-community-mcp-server-1 \
|
||||||
|
crank-community-postgres-1 \
|
||||||
|
crank-community-valkey-1; do
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
|
||||||
|
docker rm -f \"\$container\"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo 'Docker containers before freeing required ports:'
|
||||||
|
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
|
||||||
|
for port in 3000 3001 3002; do
|
||||||
|
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
|
||||||
|
if [ -n \"\$container_ids\" ]; then
|
||||||
|
echo \"Removing containers publishing port \$port\"
|
||||||
|
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
|
||||||
|
docker rm -f \$container_ids
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
|
||||||
|
fi
|
||||||
|
docker compose \$compose_profiles up -d --remove-orphans
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Verify health endpoints
|
||||||
|
run: |
|
||||||
|
. "$OPENBAO_ENV_FILE"
|
||||||
|
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||||
|
set -e
|
||||||
|
cd '$DEPLOY_PATH'
|
||||||
|
for attempt in \$(seq 1 30); do
|
||||||
|
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
|
||||||
|
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \
|
||||||
|
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo 'deployment health verification failed' >&2
|
||||||
|
docker compose ps >&2
|
||||||
|
exit 1
|
||||||
|
"
|
||||||
@@ -22,26 +22,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Use preinstalled Rust toolchain
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
toolchain_dir="${RUSTUP_HOME:-$HOME/.rustup}/toolchains/1.96.1-x86_64-unknown-linux-gnu"
|
|
||||||
toolchain_bin="$toolchain_dir/bin"
|
|
||||||
if [ ! -x "$toolchain_bin/rustc" ] || [ ! -x "$toolchain_bin/cargo" ]; then
|
|
||||||
echo "Rust 1.96.1 is not preinstalled at $toolchain_dir." >&2
|
|
||||||
echo "Install it in the Gitea runner image/host before running CI:" >&2
|
|
||||||
echo "rustup toolchain install 1.96.1 --profile minimal --component clippy --component rustfmt" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$toolchain_bin" >> "$GITHUB_PATH"
|
|
||||||
"$toolchain_bin/rustc" --version
|
|
||||||
"$toolchain_bin/cargo" --version
|
|
||||||
"$toolchain_bin/rustfmt" --version
|
|
||||||
"$toolchain_bin/cargo-clippy" --version
|
|
||||||
|
|
||||||
- name: Verify runner toolchain
|
- name: Verify runner toolchain
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
node --version
|
node --version
|
||||||
@@ -50,56 +32,6 @@ jobs:
|
|||||||
command -v bao
|
command -v bao
|
||||||
bao version
|
bao version
|
||||||
|
|
||||||
- name: Install dependency policy tool
|
|
||||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
|
||||||
|
|
||||||
- name: Run tooling unit tests
|
|
||||||
run: python3 -m unittest discover -s tests/unit
|
|
||||||
|
|
||||||
- name: Check typed runtime configuration contract
|
|
||||||
run: |
|
|
||||||
cargo run -p crank-config --bin crank-config-contract -- --check
|
|
||||||
python3 scripts/check-runtime-config.py --root .
|
|
||||||
python3 scripts/check-config-boundaries.py --root .
|
|
||||||
scripts/check-rust-boundaries.sh
|
|
||||||
|
|
||||||
- name: Check canonical migration contract
|
|
||||||
run: cargo run -p admin-api --bin crank-migrate -- plan --check
|
|
||||||
|
|
||||||
- name: Check typed metrics contract
|
|
||||||
run: |
|
|
||||||
cargo run -p crank-metrics --bin crank-metrics-contract -- --check
|
|
||||||
python3 scripts/check-metrics-boundaries.py --root .
|
|
||||||
|
|
||||||
- name: Check Capability Inventory
|
|
||||||
run: |
|
|
||||||
required_args=""
|
|
||||||
for number in $(seq 1 54); do
|
|
||||||
required_args="$required_args --required-fr FR-$number"
|
|
||||||
done
|
|
||||||
python3 scripts/validate-capability-inventory.py \
|
|
||||||
--root . \
|
|
||||||
--inventory docs/capability-inventory.json \
|
|
||||||
--schema docs/schemas/capability-inventory.schema.json \
|
|
||||||
$required_args
|
|
||||||
|
|
||||||
- name: Check Capability Baseline
|
|
||||||
run: |
|
|
||||||
python3 scripts/validate-capability-baseline.py \
|
|
||||||
--root . \
|
|
||||||
--manifest docs/capability-baseline/manifest.json \
|
|
||||||
--schema docs/schemas/capability-baseline.schema.json
|
|
||||||
|
|
||||||
- name: Check Community scope
|
|
||||||
run: scripts/check-community-scope.sh
|
|
||||||
|
|
||||||
- name: Run release quality gates
|
|
||||||
run: |
|
|
||||||
cargo fmt --all --check
|
|
||||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
|
||||||
cargo test --workspace --all-targets -- --test-threads=1
|
|
||||||
cargo deny --locked check advisories bans licenses sources
|
|
||||||
|
|
||||||
- name: Build release binaries
|
- name: Build release binaries
|
||||||
run: cargo build --release -p admin-api -p mcp-server
|
run: cargo build --release -p admin-api -p mcp-server
|
||||||
|
|
||||||
@@ -107,69 +39,22 @@ jobs:
|
|||||||
working-directory: apps/ui
|
working-directory: apps/ui
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Audit UI dependencies
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: npm audit --audit-level=high
|
|
||||||
|
|
||||||
- name: Build UI dist
|
- name: Build UI dist
|
||||||
working-directory: apps/ui
|
working-directory: apps/ui
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright browser
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run release end-to-end tests
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: |
|
|
||||||
mkdir -p ../../.tmp
|
|
||||||
rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json
|
|
||||||
PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npm run e2e
|
|
||||||
|
|
||||||
- name: Collect sanitized OpenAPI UI release evidence
|
|
||||||
working-directory: apps/ui
|
|
||||||
run: |
|
|
||||||
trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT
|
|
||||||
python3 ../../scripts/collect-capability-baseline.py playwright \
|
|
||||||
--report ../../.tmp/openapi-playwright.json \
|
|
||||||
--output ../../.tmp/openapi-ui-evidence.json \
|
|
||||||
--source-revision "$(git -C ../.. rev-parse HEAD)" \
|
|
||||||
--environment-class release \
|
|
||||||
--flow-id openapi-upload-ui \
|
|
||||||
--required-test 'operations page imports OpenAPI methods as drafts' \
|
|
||||||
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
|
|
||||||
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
|
|
||||||
--required-test 'OpenAPI apply preserves job authority after language or workspace changes' \
|
|
||||||
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
|
|
||||||
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
|
|
||||||
python3 ../../scripts/validate-capability-run.py \
|
|
||||||
--schema ../../docs/schemas/capability-baseline.schema.json \
|
|
||||||
--candidate ../../.tmp/openapi-ui-evidence.json \
|
|
||||||
--require-accepted
|
|
||||||
|
|
||||||
- name: Validate deployment manifests
|
|
||||||
run: |
|
|
||||||
docker compose -f docker-compose.yml --env-file .env.example config -q
|
|
||||||
docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml --env-file deploy/community/.env.images.example --profile local-db config -q
|
|
||||||
|
|
||||||
- name: Package release artifacts
|
- name: Package release artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/release
|
mkdir -p dist/release
|
||||||
source_revision="$(git rev-parse HEAD)"
|
|
||||||
evidence_artifact="dist/crank-openapi-ui-evidence-${IMAGE_TAG}-${source_revision}.json"
|
|
||||||
cp target/release/admin-api dist/release/admin-api
|
cp target/release/admin-api dist/release/admin-api
|
||||||
cp target/release/crank-migrate dist/release/crank-migrate
|
|
||||||
cp target/release/mcp-server dist/release/mcp-server
|
cp target/release/mcp-server dist/release/mcp-server
|
||||||
cp .tmp/openapi-ui-evidence.json "$evidence_artifact"
|
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api
|
||||||
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api crank-migrate
|
|
||||||
tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server
|
tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server
|
||||||
tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz .
|
tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz .
|
||||||
sha256sum \
|
sha256sum \
|
||||||
dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \
|
dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \
|
||||||
dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \
|
dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \
|
||||||
dist/crank-community-ui-${IMAGE_TAG}.tar.gz \
|
dist/crank-community-ui-${IMAGE_TAG}.tar.gz \
|
||||||
"$evidence_artifact" \
|
|
||||||
> dist/crank-community-${IMAGE_TAG}-checksums.txt
|
> dist/crank-community-${IMAGE_TAG}-checksums.txt
|
||||||
|
|
||||||
- name: Generate SBOM
|
- name: Generate SBOM
|
||||||
@@ -203,36 +88,6 @@ jobs:
|
|||||||
docker build -f apps/ui/Dockerfile \
|
docker build -f apps/ui/Dockerfile \
|
||||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||||
-t '${{ env.UI_IMAGE }}:latest' .
|
-t '${{ env.UI_IMAGE }}:latest' .
|
||||||
mkdir -p .tmp
|
|
||||||
cat > .tmp/release-migration-smoke.env <<EOF
|
|
||||||
COMPOSE_PROJECT_NAME=crank-release-migration-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
POSTGRES_HOST=postgres
|
|
||||||
POSTGRES_DB=crank
|
|
||||||
POSTGRES_USER=crank
|
|
||||||
POSTGRES_PASSWORD=release-smoke-password
|
|
||||||
CRANK_ADMIN_API_IMAGE=${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}
|
|
||||||
CRANK_MCP_SERVER_IMAGE=${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}
|
|
||||||
CRANK_UI_IMAGE=${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}
|
|
||||||
CRANK_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
|
||||||
CRANK_SESSION_SECRET=release-smoke-session
|
|
||||||
CRANK_PASSWORD_PEPPER=release-smoke-pepper
|
|
||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
|
||||||
CRANK_BASE_URL=http://127.0.0.1
|
|
||||||
CRANK_ENVIRONMENT=release-smoke
|
|
||||||
CRANK_PUBLISH_BIND=127.0.0.1
|
|
||||||
CRANK_ADMIN_PUBLISH_PORT=0
|
|
||||||
CRANK_MCP_PUBLISH_PORT=0
|
|
||||||
CRANK_UI_PUBLISH_PORT=0
|
|
||||||
EOF
|
|
||||||
trap 'docker compose -f deploy/community/docker-compose.images.yml --env-file .tmp/release-migration-smoke.env --profile local-db down -v --remove-orphans || true' EXIT
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/release-migration-smoke.env --profile local-db up -d --wait
|
|
||||||
docker compose -f deploy/community/docker-compose.images.yml \
|
|
||||||
--env-file .tmp/release-migration-smoke.env --profile local-db logs migrate | grep '"status":"applied"'
|
|
||||||
scripts/scan-images.sh \
|
|
||||||
'${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
'${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
|
||||||
'${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
|
||||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||||
docker push '${{ env.ADMIN_API_IMAGE }}:latest'
|
docker push '${{ env.ADMIN_API_IMAGE }}:latest'
|
||||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||||
|
|||||||
-10
@@ -18,15 +18,5 @@ apps/ui/test-results
|
|||||||
apps/ui/vite.config.js
|
apps/ui/vite.config.js
|
||||||
apps/ui/vite.config.d.ts
|
apps/ui/vite.config.d.ts
|
||||||
*.log
|
*.log
|
||||||
__pycache__/
|
|
||||||
__*.md
|
__*.md
|
||||||
diploma/
|
diploma/
|
||||||
AGENTS.md
|
|
||||||
TASKS.md
|
|
||||||
|
|
||||||
# BMAD workspace data and generated artifacts
|
|
||||||
**/_bmad*/
|
|
||||||
.bmad-loop/runs/
|
|
||||||
.bmad-loop/cache/
|
|
||||||
.bmad-loop/policy.toml
|
|
||||||
_bmad/render/
|
|
||||||
|
|||||||
-328
@@ -1,328 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "display_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "category",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "protocol",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "security_level",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "operation_created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "operation_published_at: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "target_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "input_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "output_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "input_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 16,
|
|
||||||
"name": "output_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 17,
|
|
||||||
"name": "execution_config_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "execution_config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 18,
|
|
||||||
"name": "tool_description_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "tool_description_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 19,
|
|
||||||
"name": "samples_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "samples_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 20,
|
|
||||||
"name": "generated_draft_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "generated_draft_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "config_export_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "config_export_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 22,
|
|
||||||
"name": "wizard_state_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "wizard_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 23,
|
|
||||||
"name": "change_note",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "change_note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 25,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "09a122a463d94b08719b16c221d266b9b2061da3eb19f0b16e1069ca30c4e60b"
|
|
||||||
}
|
|
||||||
+14
-98
@@ -6,156 +6,72 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "category",
|
"name": "category",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "protocol",
|
"name": "protocol",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "security_level",
|
"name": "security_level",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "target_json",
|
"name": "target_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"name": "current_draft_version",
|
"name": "current_draft_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "current_draft_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "latest_published_version",
|
"name": "latest_published_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "latest_published_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
"name": "published_at: time::OffsetDateTime",
|
"name": "published_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
-330
@@ -1,330 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "display_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "category",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "protocol",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "security_level",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "operation_created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "operation_published_at: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "target_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "input_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "output_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "input_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 16,
|
|
||||||
"name": "output_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 17,
|
|
||||||
"name": "execution_config_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "execution_config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 18,
|
|
||||||
"name": "tool_description_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "tool_description_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 19,
|
|
||||||
"name": "samples_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "samples_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 20,
|
|
||||||
"name": "generated_draft_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "generated_draft_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "config_export_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "config_export_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 22,
|
|
||||||
"name": "wizard_state_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "wizard_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 23,
|
|
||||||
"name": "change_note",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "change_note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 25,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Int4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "0c0a4e5d7b11cd7df7aaf34fd3b7c294da65804186907e015da9420d594fefda"
|
|
||||||
}
|
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "security_level",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "operation_created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "operation_published_at: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "version",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "target_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "input_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "output_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
|
"name": "input_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 16,
|
||||||
|
"name": "output_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "execution_config_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tool_description_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 19,
|
||||||
|
"name": "samples_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 20,
|
||||||
|
"name": "generated_draft_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 21,
|
||||||
|
"name": "config_export_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
|
"name": "change_note",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 23,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "10ac4260e678ac458750988f95e54d3a84d9870e10e86bbe0d9c9fd99bf8a1f5"
|
||||||
|
}
|
||||||
+6
-42
@@ -6,68 +6,32 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "agent_id",
|
"name": "agent_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "agent_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "version",
|
"name": "version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "instructions_json",
|
"name": "instructions_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "instructions_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "tool_selection_policy_json",
|
"name": "tool_selection_policy_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "tool_selection_policy_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+8
-56
@@ -6,90 +6,42 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "operation_id",
|
"name": "operation_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "operation_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "version",
|
"name": "version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "sample_kind",
|
"name": "sample_kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "sample_kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "storage_ref",
|
"name": "storage_ref",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "storage_ref"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "content_type",
|
"name": "content_type",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "content_type"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "file_name",
|
"name": "file_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "file_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_samples",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+11
-77
@@ -6,123 +6,57 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "description",
|
"name": "description",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "current_draft_version",
|
"name": "current_draft_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "current_draft_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "latest_published_version",
|
"name": "latest_published_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "latest_published_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "published_at: time::OffsetDateTime",
|
"name": "published_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+5
-35
@@ -6,57 +6,27 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "email"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "created_at!: OffsetDateTime",
|
"name": "created_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "allowed!",
|
"name": "allowed!",
|
||||||
"type_info": "Bool",
|
"type_info": "Bool"
|
||||||
"origin": "Expression"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "present!",
|
"name": "present!",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": "Expression"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where email = $1\n limit 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash!",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where id = $1\n limit 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "password_hash!",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06"
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "insert into users (\n id,\n email,\n display_name,\n password_hash,\n status,\n created_at\n ) values (\n $1, $2, $3, $4, 'active', now()\n )\n on conflict (email) do update\n set display_name = excluded.display_name,\n password_hash = excluded.password_hash,\n status = 'active'\n returning id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08"
|
||||||
|
}
|
||||||
-329
@@ -1,329 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "display_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "category",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "protocol",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "security_level",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "operation_created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "operation_published_at: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "target_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "input_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "output_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "input_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 16,
|
|
||||||
"name": "output_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 17,
|
|
||||||
"name": "execution_config_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "execution_config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 18,
|
|
||||||
"name": "tool_description_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "tool_description_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 19,
|
|
||||||
"name": "samples_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "samples_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 20,
|
|
||||||
"name": "generated_draft_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "generated_draft_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "config_export_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "config_export_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 22,
|
|
||||||
"name": "wizard_state_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "wizard_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 23,
|
|
||||||
"name": "change_note",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "change_note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 25,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "3e015fdc582e6cbbcc0e8d8863873f4e3113fd89f806fb4e0592c0200283f599"
|
|
||||||
}
|
|
||||||
-396
@@ -1,396 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_slug",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "agent_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "agent_slug",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "tool_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_operation_bindings",
|
|
||||||
"name": "tool_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "tool_title",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_operation_bindings",
|
|
||||||
"name": "tool_title"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "tool_description!",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": "Expression"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "display_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "category",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "protocol",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "security_level",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "operation_created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "operation_published_at: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 16,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 17,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 18,
|
|
||||||
"name": "target_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 19,
|
|
||||||
"name": "input_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 20,
|
|
||||||
"name": "output_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "input_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 22,
|
|
||||||
"name": "output_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 23,
|
|
||||||
"name": "execution_config_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "execution_config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "tool_description_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "tool_description_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 25,
|
|
||||||
"name": "samples_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "samples_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 26,
|
|
||||||
"name": "generated_draft_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "generated_draft_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 27,
|
|
||||||
"name": "config_export_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "config_export_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 28,
|
|
||||||
"name": "wizard_state_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "wizard_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 29,
|
|
||||||
"name": "change_note",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "change_note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 30,
|
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 31,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "4699ba47e6489b840ada38a7491f071f605dabd061992cd0a567241a8de1fa0c"
|
|
||||||
}
|
|
||||||
+7
-49
@@ -6,79 +6,37 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "settings_json",
|
"name": "settings_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "settings_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "security_level",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "operation_created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "operation_published_at: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "version",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "target_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "input_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "output_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
|
"name": "input_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 16,
|
||||||
|
"name": "output_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "execution_config_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tool_description_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 19,
|
||||||
|
"name": "samples_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 20,
|
||||||
|
"name": "generated_draft_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 21,
|
||||||
|
"name": "config_export_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
|
"name": "change_note",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 23,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "67a73cb5211a0c23d2e8d8361a8e37c488eb2420207e9c4fa682993eda20cd62"
|
||||||
|
}
|
||||||
+8
-56
@@ -6,90 +6,42 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "operation_id",
|
"name": "operation_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "operation_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "version",
|
"name": "version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "descriptor_kind",
|
"name": "descriptor_kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "descriptor_kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "storage_ref",
|
"name": "storage_ref",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "storage_ref"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "source_name",
|
"name": "source_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "source_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "package_index_json",
|
"name": "package_index_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "package_index_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "descriptors",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "present!",
|
"name": "present!",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": "Expression"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "security_level",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "operation_created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "operation_published_at: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "version",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "target_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "input_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "output_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
|
"name": "input_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 16,
|
||||||
|
"name": "output_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "execution_config_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tool_description_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 19,
|
||||||
|
"name": "samples_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 20,
|
||||||
|
"name": "generated_draft_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 21,
|
||||||
|
"name": "config_export_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
|
"name": "change_note",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 23,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7ad59ce20834c3de2b0345c1be28d84377b417efb30c7bdc38d4e3746a0c1a7a"
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n w.created_at as \"created_at!: time::OffsetDateTime\",\n w.updated_at as \"updated_at!: time::OffsetDateTime\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "settings_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb"
|
||||||
|
}
|
||||||
+11
-77
@@ -6,123 +6,57 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "description",
|
"name": "description",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "current_draft_version",
|
"name": "current_draft_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "current_draft_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "latest_published_version",
|
"name": "latest_published_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "latest_published_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "published_at: time::OffsetDateTime",
|
"name": "published_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "operation_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "mode",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "cursor_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "state_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "expires_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "last_poll_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "closed_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57"
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "security_level",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "operation_created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "operation_published_at: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "version",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "target_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "input_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "output_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
|
"name": "input_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 16,
|
||||||
|
"name": "output_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "execution_config_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tool_description_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 19,
|
||||||
|
"name": "samples_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 20,
|
||||||
|
"name": "generated_draft_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 21,
|
||||||
|
"name": "config_export_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
|
"name": "change_note",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 23,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a24c36f988151016f9ffa834a6a5658f7f094844b892c999e6f58d6f92fbe0c2"
|
||||||
|
}
|
||||||
-326
@@ -1,326 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "select\n o.id,\n o.workspace_id,\n ov.name,\n ov.display_name,\n ov.category,\n ov.protocol,\n ov.security_level,\n ov.created_at as \"operation_created_at!: time::OffsetDateTime\",\n ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",\n ov.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "display_name",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "category",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "protocol",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "security_level",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "operation_created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "operation_published_at: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "target_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "input_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "output_schema_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_schema_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "input_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "input_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 16,
|
|
||||||
"name": "output_mapping_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "output_mapping_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 17,
|
|
||||||
"name": "execution_config_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "execution_config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 18,
|
|
||||||
"name": "tool_description_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "tool_description_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 19,
|
|
||||||
"name": "samples_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "samples_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 20,
|
|
||||||
"name": "generated_draft_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "generated_draft_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "config_export_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "config_export_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 22,
|
|
||||||
"name": "wizard_state_json",
|
|
||||||
"type_info": "Jsonb",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "wizard_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 23,
|
|
||||||
"name": "change_note",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "change_note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
|
||||||
"type_info": "Timestamptz",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 25,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Text",
|
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": []
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "b531368c52ef70664dfe75fe801e52e70fc74af8e782989cf4aea3f1170d33e5"
|
|
||||||
}
|
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n and ($5::text is null or mode = $5)\n order by created_at desc\n limit $6",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "operation_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "mode",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "cursor_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "state_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "expires_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "last_poll_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "closed_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93"
|
||||||
|
}
|
||||||
+7
-49
@@ -6,79 +6,37 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "config_json",
|
"name": "config_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_at!: OffsetDateTime",
|
"name": "created_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "updated_at!: OffsetDateTime",
|
"name": "updated_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n protocol_version,\n initialized,\n workspace_slug,\n agent_slug,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n expires_at as \"expires_at: OffsetDateTime\"\n from mcp_transport_sessions\n where id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "protocol_version",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "initialized",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "workspace_slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "agent_slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "expires_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c"
|
||||||
|
}
|
||||||
+8
-56
@@ -6,90 +6,42 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "memberships",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "memberships",
|
|
||||||
"name": "user_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "memberships",
|
|
||||||
"name": "role"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "memberships",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "email"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "user_created_at!: time::OffsetDateTime",
|
"name": "user_created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+9
-63
@@ -6,101 +6,47 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "current_version",
|
"name": "current_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "current_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "last_used_at: time::OffsetDateTime",
|
"name": "last_used_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "last_used_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+7
-49
@@ -6,79 +6,37 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "slug",
|
"name": "slug",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "settings_json",
|
"name": "settings_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "settings_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "workspaces",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+9
-63
@@ -6,101 +6,47 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "current_version",
|
"name": "current_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "current_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "last_used_at: time::OffsetDateTime",
|
"name": "last_used_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secrets",
|
|
||||||
"name": "last_used_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "agent_slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "tool_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "tool_title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "tool_description!",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "display_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "category",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "protocol",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "security_level",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "operation_created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
|
"name": "operation_published_at: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 16,
|
||||||
|
"name": "version",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "target_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 19,
|
||||||
|
"name": "input_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 20,
|
||||||
|
"name": "output_schema_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 21,
|
||||||
|
"name": "input_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
|
"name": "output_mapping_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 23,
|
||||||
|
"name": "execution_config_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "tool_description_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 25,
|
||||||
|
"name": "samples_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 26,
|
||||||
|
"name": "generated_draft_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 27,
|
||||||
|
"name": "config_export_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 28,
|
||||||
|
"name": "change_note",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 29,
|
||||||
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 30,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "dd4a415a042b863b1034727737f3d14019ba405a07a8360b68e8c9c5597e17ce"
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n order by updated_at desc\n limit $5",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "operation_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "progress_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "result_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "error_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "expires_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "last_poll_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "updated_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "finished_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b"
|
||||||
|
}
|
||||||
+8
-56
@@ -6,90 +6,42 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "email"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "role"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "token_hash",
|
"name": "token_hash",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "token_hash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "expires_at!: time::OffsetDateTime",
|
"name": "expires_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "expires_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "invitation_tokens",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+14
-98
@@ -6,156 +6,72 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "category",
|
"name": "category",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "protocol",
|
"name": "protocol",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "protocol"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "security_level",
|
"name": "security_level",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "security_level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "target_json",
|
"name": "target_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operation_versions",
|
|
||||||
"name": "target_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"name": "current_draft_version",
|
"name": "current_draft_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "current_draft_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "latest_published_version",
|
"name": "latest_published_version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "latest_published_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "updated_at!: time::OffsetDateTime",
|
"name": "updated_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
"name": "published_at: time::OffsetDateTime",
|
"name": "published_at: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "operations",
|
|
||||||
"name": "published_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "operation_id",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "progress_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "result_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "error_json",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "expires_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "last_poll_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "created_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "updated_at!: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "finished_at: OffsetDateTime",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8"
|
||||||
|
}
|
||||||
+7
-49
@@ -6,79 +6,37 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "user_sessions",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "user_sessions",
|
|
||||||
"name": "user_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "current_workspace_id",
|
"name": "current_workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "user_sessions",
|
|
||||||
"name": "current_workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "email"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "created_at!: OffsetDateTime",
|
"name": "created_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "users",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+7
-49
@@ -6,79 +6,37 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "workspace_id",
|
"name": "workspace_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "workspace_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "kind"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "config_json",
|
"name": "config_json",
|
||||||
"type_info": "Jsonb",
|
"type_info": "Jsonb"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "config_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_at!: OffsetDateTime",
|
"name": "created_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "updated_at!: OffsetDateTime",
|
"name": "updated_at!: OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "auth_profiles",
|
|
||||||
"name": "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+6
-42
@@ -6,68 +6,32 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "secret_id",
|
"name": "secret_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "secret_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "version",
|
"name": "version",
|
||||||
"type_info": "Int4",
|
"type_info": "Int4"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "ciphertext",
|
"name": "ciphertext",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "ciphertext"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "key_version",
|
"name": "key_version",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "key_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "created_at!: time::OffsetDateTime",
|
"name": "created_at!: time::OffsetDateTime",
|
||||||
"type_info": "Timestamptz",
|
"type_info": "Timestamptz"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "created_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "created_by",
|
"name": "created_by",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "secret_versions",
|
|
||||||
"name": "created_by"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+4
-28
@@ -6,46 +6,22 @@
|
|||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "operation_id",
|
"name": "operation_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agent_operation_bindings",
|
|
||||||
"name": "operation_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"name": "agent_id",
|
"name": "agent_id",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "agent_slug",
|
"name": "agent_slug",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "slug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "display_name",
|
"name": "display_name",
|
||||||
"type_info": "Text",
|
"type_info": "Text"
|
||||||
"origin": {
|
|
||||||
"Table": {
|
|
||||||
"table": "agents",
|
|
||||||
"name": "display_name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This repository is developed through agent-assisted workflow. Follow the repository documents first, then implement code.
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
Use the documents in this order when there is ambiguity:
|
||||||
|
|
||||||
|
1. `docs/architecture.md`
|
||||||
|
2. `docs/module-decomposition.md`
|
||||||
|
3. `docs/data-model.md`
|
||||||
|
4. `docs/database-schema.md`
|
||||||
|
5. `docs/admin-api.md`
|
||||||
|
6. `docs/mcp-interface.md`
|
||||||
|
7. `docs/rust-design.md`
|
||||||
|
8. `docs/development-rules.md`
|
||||||
|
9. `docs/rust-code-rules.md`
|
||||||
|
10. `docs/implementation-plan.md`
|
||||||
|
11. `docs/product-editions.md`
|
||||||
|
12. `docs/commercial-boundaries.md`
|
||||||
|
13. `docs/frontend-roadmap.md`
|
||||||
|
14. `docs/refactoring-roadmap.md`
|
||||||
|
|
||||||
|
If code and docs diverge, update docs first or together with code.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Follow `Red -> Green -> Refactor -> Commit`.
|
||||||
|
- Backend changes follow `TDD` strictly. Frontend layout, copy, and UX cleanup do not require strict `TDD`, but working frontend behavior should still be verified before commit.
|
||||||
|
- Large features use their own branch: `feat/<feature-name>`.
|
||||||
|
- Commits must be atomic.
|
||||||
|
- Push periodically after one or more logically complete `RGR + commit` cycles.
|
||||||
|
- Do not wait for the whole feature to be finished before pushing.
|
||||||
|
- Delete merged feature branches both locally and in `origin`.
|
||||||
|
- During the current refactoring and small-fix phase, changes are merged directly into `main` on GitHub without opening PRs by default.
|
||||||
|
|
||||||
|
## Language rules
|
||||||
|
|
||||||
|
- Commit messages must be in English.
|
||||||
|
- Code identifiers must be in English.
|
||||||
|
- Code comments are avoided by default.
|
||||||
|
- If a code comment is truly unavoidable, it must be in English.
|
||||||
|
|
||||||
|
## Code rules
|
||||||
|
|
||||||
|
- Prefer self-documenting code.
|
||||||
|
- Keep domain logic separate from storage, transport, and orchestration.
|
||||||
|
- Do not create god-structs or giant services.
|
||||||
|
- Keep `pub` surface minimal.
|
||||||
|
- Avoid `unwrap`, `expect`, `todo`, `dbg`, and `panic` in production code.
|
||||||
|
- `unsafe` is forbidden by default.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Use the canonical commands from `justfile`:
|
||||||
|
|
||||||
|
- `just fmt`
|
||||||
|
- `just fmt-check`
|
||||||
|
- `just check`
|
||||||
|
- `just clippy`
|
||||||
|
- `just test`
|
||||||
|
- `just verify`
|
||||||
|
|
||||||
|
## Current execution mode
|
||||||
|
|
||||||
|
- Build the Rust workspace first.
|
||||||
|
- Keep the UI as a separate app outside the Cargo workspace.
|
||||||
|
- Implement one vertical slice at a time.
|
||||||
|
|
||||||
|
## Task tracking
|
||||||
|
|
||||||
|
- Check `TASKS.md` before starting a new piece of work.
|
||||||
|
- Update `TASKS.md` when a task starts, finishes, or gets blocked.
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Contributor License Agreement
|
|
||||||
|
|
||||||
Этот документ описывает условия, на которых проект Crank принимает внешние вклады в код, документацию, тесты, примеры и другие материалы.
|
|
||||||
|
|
||||||
Перед отправкой pull request автор вклада должен согласиться с этими условиями. Если вклад сделан от имени компании, автор подтверждает, что имеет право передать вклад на этих условиях.
|
|
||||||
|
|
||||||
## 1. Что считается вкладом
|
|
||||||
|
|
||||||
Вкладом считается любой материал, намеренно отправленный в проект Crank:
|
|
||||||
|
|
||||||
- исходный код;
|
|
||||||
- тесты;
|
|
||||||
- документация;
|
|
||||||
- примеры конфигурации;
|
|
||||||
- исправления ошибок;
|
|
||||||
- предложения, если они оформлены как конкретные изменения в репозитории.
|
|
||||||
|
|
||||||
## 2. Права на вклад
|
|
||||||
|
|
||||||
Автор вклада подтверждает, что:
|
|
||||||
|
|
||||||
- он является правообладателем вклада или имеет необходимые права для его передачи;
|
|
||||||
- вклад не нарушает права третьих лиц;
|
|
||||||
- вклад не содержит кода или материалов, которые нельзя использовать в проекте Crank на условиях этого соглашения.
|
|
||||||
|
|
||||||
## 3. Лицензия на вклад
|
|
||||||
|
|
||||||
Автор предоставляет владельцу проекта Crank бессрочную, всемирную, безотзывную, неисключительную, безвозмездную лицензию на использование вклада.
|
|
||||||
|
|
||||||
Эта лицензия включает право:
|
|
||||||
|
|
||||||
- использовать вклад;
|
|
||||||
- копировать вклад;
|
|
||||||
- изменять вклад;
|
|
||||||
- объединять вклад с другими материалами;
|
|
||||||
- распространять вклад;
|
|
||||||
- публиковать вклад;
|
|
||||||
- сублицензировать вклад;
|
|
||||||
- включать вклад в проект Crank и производные работы.
|
|
||||||
|
|
||||||
## 4. Патенты
|
|
||||||
|
|
||||||
Если вклад затрагивает патентуемые решения, автор предоставляет владельцу проекта и пользователям Crank безвозмездную лицензию на патентные притязания автора, необходимые для использования вклада в составе Crank.
|
|
||||||
|
|
||||||
## 5. Отсутствие гарантий
|
|
||||||
|
|
||||||
Вклад передается без гарантий. Автор не отвечает за убытки, возникшие из-за использования вклада, если иное прямо не установлено законом или отдельным письменным соглашением.
|
|
||||||
|
|
||||||
## 6. Лицензия проекта
|
|
||||||
|
|
||||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
|
||||||
|
|
||||||
Владелец проекта может использовать принятые вклады в проекте Crank и производных работах.
|
|
||||||
|
|
||||||
## 7. Как подтвердить согласие
|
|
||||||
|
|
||||||
Отправляя pull request, автор подтверждает согласие с этим CLA.
|
|
||||||
|
|
||||||
В комментарии к pull request можно указать:
|
|
||||||
|
|
||||||
```text
|
|
||||||
I agree to the Crank Contributor License Agreement.
|
|
||||||
```
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Участие в разработке
|
|
||||||
|
|
||||||
Crank Community принимает исправления ошибок, улучшения документации, тесты и доработки открытой версии проекта.
|
|
||||||
|
|
||||||
## Лицензия вкладов
|
|
||||||
|
|
||||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
|
||||||
|
|
||||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы права на принятые изменения были оформлены явно и проект мог развиваться без юридических неопределенностей.
|
|
||||||
|
|
||||||
## Перед pull request
|
|
||||||
|
|
||||||
Проверьте форматирование и тесты:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just fmt-check
|
|
||||||
just clippy
|
|
||||||
just test
|
|
||||||
```
|
|
||||||
|
|
||||||
Для изменений веб-интерфейса также выполните:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/ui
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Требования к изменениям
|
|
||||||
|
|
||||||
- Не добавляйте функциональность вне границ Community-версии.
|
|
||||||
- Сверяйте границу и статус flow с
|
|
||||||
[`docs/capability-inventory.json`](./docs/capability-inventory.json): только
|
|
||||||
`implemented` считается готовым, а `planned`, `gap` и `blocked` не являются
|
|
||||||
разрешением заявить незавершённую функцию работающей.
|
|
||||||
- Если change меняет capability status или evidence, обновите versioned
|
|
||||||
[`docs/capability-baseline/manifest.json`](./docs/capability-baseline/manifest.json),
|
|
||||||
sanitized results и SHA-256. Нельзя вручную объявлять pass для failed, flaky,
|
|
||||||
skipped, not-run или manual-only evidence.
|
|
||||||
- Не добавляйте секреты, токены, приватные адреса и локальные настройки.
|
|
||||||
- Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы.
|
|
||||||
- Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx.
|
|
||||||
- Изменение PostgreSQL schema начинается с новой append-only migration в
|
|
||||||
`crank-registry`; baseline v1 не редактируется. Выполните
|
|
||||||
`just migration-contract-check` и PostgreSQL migration integration suite.
|
|
||||||
- Для пользовательских изменений обновляйте документацию или примеры.
|
|
||||||
- Runtime environment contract изменяется только через registry
|
|
||||||
`crates/crank-config`. После изменения выполните
|
|
||||||
`just config-contract-check`; generated sections `.env.example`, Compose и
|
|
||||||
parameter reference не поддерживаются независимыми ручными таблицами.
|
|
||||||
|
|
||||||
## Границы проекта
|
|
||||||
|
|
||||||
В этом репозитории поддерживаются:
|
|
||||||
|
|
||||||
- REST API как источник MCP-инструментов;
|
|
||||||
- простая авторизация администратора;
|
|
||||||
- ключи агентов для MCP-доступа;
|
|
||||||
- одно самостоятельное развертывание;
|
|
||||||
- PostgreSQL как основное хранилище;
|
|
||||||
- необязательный Valkey или Redis для служебного кэша.
|
|
||||||
|
|
||||||
Функции за пределами перечисленного набора не должны попадать в этот репозиторий.
|
|
||||||
Целевые Resources, Prompts, Tasks и Load Runs допускаются только через
|
|
||||||
проверяемое изменение Inventory, guardrails, tests и документации.
|
|
||||||
Generated
+679
-2176
File diff suppressed because it is too large
Load Diff
+8
-43
@@ -4,73 +4,38 @@ members = [
|
|||||||
"apps/mcp-server",
|
"apps/mcp-server",
|
||||||
"crates/crank-community-auth",
|
"crates/crank-community-auth",
|
||||||
"crates/crank-community-mcp",
|
"crates/crank-community-mcp",
|
||||||
"crates/crank-config",
|
|
||||||
"crates/crank-core",
|
"crates/crank-core",
|
||||||
"crates/crank-import",
|
|
||||||
"crates/crank-schema",
|
"crates/crank-schema",
|
||||||
"crates/crank-mapping",
|
"crates/crank-mapping",
|
||||||
"crates/crank-metrics",
|
|
||||||
"crates/crank-observability",
|
|
||||||
"crates/crank-registry",
|
"crates/crank-registry",
|
||||||
"crates/crank-runtime",
|
"crates/crank-runtime",
|
||||||
"crates/crank-test-support",
|
|
||||||
"crates/crank-trace",
|
|
||||||
"crates/crank-adapter-rest",
|
"crates/crank-adapter-rest",
|
||||||
"crates/crank-artifacts",
|
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-only"
|
license = "MIT"
|
||||||
rust-version = "1.96"
|
rust-version = "1.85"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
publish = false
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = "0.8"
|
||||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
metrics = "0.24.6"
|
rand = "0.8"
|
||||||
metrics-exporter-prometheus = { version = "0.18.3", default-features = false }
|
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||||
opentelemetry = { version = "0.32.0", default-features = false, features = ["trace"] }
|
|
||||||
opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace"] }
|
|
||||||
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["gen-tonic-messages", "trace"] }
|
|
||||||
opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["trace"] }
|
|
||||||
percent-encoding = "2"
|
|
||||||
prost = "0.14"
|
|
||||||
rand = "0.10"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "rustls-tls"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
sentry = { version = "0.49.0", default-features = false, features = ["backtrace", "panic", "rustls", "ureq"] }
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "json", "time", "uuid"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] }
|
||||||
subtle = "2.6"
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
time = { version = "0.3.53", features = ["formatting", "parsing", "serde"] }
|
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
tower = "0.5"
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-opentelemetry = { version = "0.33.0", default-features = false }
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
url = "2"
|
|
||||||
uuid = { version = "1", features = ["serde", "v7"] }
|
uuid = { version = "1", features = ["serde", "v7"] }
|
||||||
testcontainers = { version = "0.27", features = ["blocking"] }
|
|
||||||
testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] }
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
debug = "line-tables-only"
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
debug = false
|
|
||||||
|
|
||||||
[profile.test]
|
|
||||||
debug = "line-tables-only"
|
|
||||||
|
|
||||||
[profile.test.package."*"]
|
|
||||||
debug = false
|
|
||||||
|
|||||||
@@ -1,661 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Copyright (c) 2026 bsodfather
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this license document, but changing it is not allowed.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
Preamble
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
furnished to do so, subject to the following conditions:
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
share and change all versions of a program--to make sure it remains free
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
software for all its users.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
When we speak of free software, we are referring to freedom, not
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
SOFTWARE.
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
Crank
|
|
||||||
Copyright 2026 bsodfather
|
|
||||||
|
|
||||||
This product includes software developed for the Crank project.
|
|
||||||
|
|
||||||
Crank Community is licensed under the GNU Affero General Public License
|
|
||||||
version 3 only.
|
|
||||||
@@ -1,298 +1,162 @@
|
|||||||
# Crank
|
# Crank
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Crank - это свободная платформа для создания MCP-инструментов из REST API эндпоинтов.
|
Crank - платформа для публикации внешних API в виде MCP tools без написания отдельного backend-кода под каждую интеграцию. Целевая модель проекта строится вокруг связки `workspace -> agent -> operations`.
|
||||||
|
|
||||||
Сервис ставится на собственный сервер, подключается к PostgreSQL и дает веб-интерфейс, в котором можно описать REST API, проверить вызов, опубликовать его как инструмент и подключить к MCP-клиенту.
|
## Цели
|
||||||
|
|
||||||
## Что умеет Crank
|
- Разработать MCP server на Rust.
|
||||||
|
- Поддержать динамическое добавление интеграций через UI или конфигурацию.
|
||||||
- Создавать REST-инструменты через веб-интерфейс или YAML.
|
- Обеспечить единый сценарий работы оператора для REST, GraphQL, gRPC, WebSocket и SOAP.
|
||||||
- Принимать параметры от MCP-клиента и подставлять их в путь, query string, заголовки или тело REST-запроса.
|
- Нормализовать внешние протоколы в единую внутреннюю модель операции.
|
||||||
- Преобразовывать ответ REST API в структурированный результат для MCP-клиента.
|
- Ограничивать набор tools на уровне конкретного агента, а не отдавать один глобальный каталог.
|
||||||
- Публиковать только выбранные инструменты для конкретного агента.
|
- Поддержать workspace-изоляцию, platform access и observability.
|
||||||
- Хранить версии, черновики, примеры запросов и ответов, секреты, журналы вызовов и статистику в PostgreSQL.
|
|
||||||
- Работать с простой авторизацией администратора: email, пароль и браузерная сессия.
|
## Целевая модель продукта
|
||||||
- Запускаться через Docker Compose.
|
|
||||||
|
- `Workspace` как tenant boundary.
|
||||||
## Быстрый запуск через Docker
|
- `Operation` как интеграционный контракт.
|
||||||
|
- `Agent` как curated MCP surface для LLM.
|
||||||
Требования:
|
- Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`.
|
||||||
|
- Поддержка GraphQL для `query` и `mutation`.
|
||||||
- Docker;
|
- Поддержка unary и bounded server-streaming для gRPC.
|
||||||
- Docker Compose;
|
- Поддержка WebSocket upstream integrations в bounded execution modes.
|
||||||
- свободные порты `3000`, `3001`, `3002`;
|
- Поддержка SOAP/WSDL enterprise integrations.
|
||||||
- доступ к опубликованным Docker-образам Crank.
|
- Поддержка controlled streaming modes поверх MCP `Streamable HTTP`.
|
||||||
|
- Platform API keys и membership layer.
|
||||||
Создайте рабочую папку:
|
- Observability: invocation logs, usage aggregates, latency/error metrics.
|
||||||
|
- Импорт и экспорт operation-конфигураций в `YAML`.
|
||||||
```bash
|
- Использование `JSONPath` для точечного маппинга.
|
||||||
mkdir -p crank
|
|
||||||
cd crank
|
## Структура документации
|
||||||
```
|
|
||||||
|
- `docs/architecture.md` - целевая архитектура системы.
|
||||||
Скачайте compose-файл и пример настроек:
|
- `docs/as-is-to-be.md` - переход `as is -> to be`, page-by-page gap analysis и архитектурные конфликты.
|
||||||
|
- `docs/backend-gap-plan.md` - конкретный backend-план: сущности, API, БД и порядок реализации.
|
||||||
```bash
|
- `docs/operations-workspace-contracts.md` - точные `workspace-scoped` контракты для экранов `Operations` и `Wizard`.
|
||||||
curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml
|
- `docs/alpine-ui-integration-plan.md` - постраничный план подключения нового Alpine UI к реальному backend.
|
||||||
curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example
|
- `docs/module-decomposition.md` - декомпозиция crates и модулей.
|
||||||
cp .env.example .env
|
- `docs/data-model.md` - целевая модель данных.
|
||||||
```
|
- `docs/database-schema.md` - целевая схема БД.
|
||||||
|
- `docs/admin-api.md` - целевые HTTP-контракты административного API.
|
||||||
Откройте скачанный `.env.example` и перенесите нужные значения в `.env`.
|
- `docs/diagrams.md` - диаграммы компонентов, сущностей и БД.
|
||||||
|
- `docs/mcp-interface.md` - модель MCP transport и agent-scoped publishing.
|
||||||
Минимально нужно заменить:
|
- `docs/testing-strategy.md` - стратегия тестирования.
|
||||||
|
- `docs/manual-regression-checklist.md` - post-integration regression baseline и ручной smoke checklist.
|
||||||
- `POSTGRES_PASSWORD`;
|
- `docs/runtime-config.md` - конфигурация окружения.
|
||||||
- `CRANK_MASTER_KEY`;
|
- `docs/deployment.md` - деплой, reverse proxy и CI/CD.
|
||||||
- `CRANK_SESSION_SECRET`;
|
- `docs/deploy-and-staging-smoke.md` - канонический post-deploy smoke pass для staging/production-like окружения.
|
||||||
- `CRANK_PASSWORD_PEPPER`;
|
- `docs/authenticated-staging-pass.md` - browser-authenticated pass для UI flows, secrets, wizard и protocol smoke на стенде.
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_EMAIL`;
|
- `docs/staging-regression-notes.md` - журнал реальных замечаний и результатов post-deploy проверок на стенде.
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`;
|
- `docs/demo-runbook.md` - демонстрационный сценарий.
|
||||||
- `CRANK_BASE_URL`.
|
- `docs/public-smoke-targets.md` - готовые публичные upstream-сервисы и payload-ы для smoke-проверки MCP.
|
||||||
|
- `docs/secrets-auth-plan.md` - целевая модель upstream secrets, auth profiles и пошаговый план реализации.
|
||||||
Секреты можно сгенерировать так:
|
- `docs/streaming-mcp-plan.md` - целевая модель MCP transport streaming, upstream streaming и поэтапный план реализации.
|
||||||
|
- `docs/streaming-admin-api.md` - точные HTTP-контракты и DTO для streaming configuration, sessions и jobs.
|
||||||
```bash
|
- `docs/streaming-runtime-design.md` - функция-за-функцией разложенная streaming runtime architecture.
|
||||||
openssl rand -hex 32
|
- `docs/streaming-ui-contract.md` - точный UI-контракт для streaming configuration и test flows.
|
||||||
```
|
- `docs/protocol-capability-matrix.md` - capability matrix по всем protocol families и execution modes.
|
||||||
|
- `docs/streaming-implementation-spec.md` - execution-oriented план реализации по срезам, файлам, тестам и DoD.
|
||||||
Запустите Crank:
|
- `docs/rust-design.md` - правила распределения поведения в Rust.
|
||||||
|
- `docs/development-rules.md` - правила разработки и workflow.
|
||||||
```bash
|
- `docs/rust-code-rules.md` - Rust-specific coding rules.
|
||||||
docker compose --profile local-db up -d
|
- `docs/implementation-plan.md` - порядок перехода от текущего состояния к целевой модели.
|
||||||
```
|
- `docs/protocols/rest.md` - требования и ограничения для REST.
|
||||||
|
- `docs/protocols/graphql.md` - требования и ограничения для GraphQL.
|
||||||
Если registry требует авторизацию, сначала выполните `docker login git.itexp.me`.
|
- `docs/protocols/grpc.md` - требования и ограничения для gRPC.
|
||||||
|
- `docs/protocols/websocket.md` - требования и ограничения для WebSocket.
|
||||||
После запуска:
|
- `docs/protocols/soap.md` - требования и ограничения для SOAP.
|
||||||
|
|
||||||
- веб-интерфейс: `http://localhost:3000`;
|
## Ключевая идея продукта
|
||||||
- HTTP API панели управления: `http://localhost:3001`;
|
|
||||||
- MCP-сервер: `http://localhost:3002`.
|
Система строится вокруг трех уровней:
|
||||||
|
|
||||||
По умолчанию порты публикуются только на `127.0.0.1`. Это удобно, если перед Crank стоит nginx, Caddy или другой обратный прокси. Если нужно открыть порты наружу напрямую, укажите в `.env`:
|
- `Workspace` - граница данных и доступа команды.
|
||||||
|
- `Agent` - curated MCP endpoint для конкретного сценария LLM.
|
||||||
```env
|
- `Operation` - низкоуровневый интеграционный контракт.
|
||||||
CRANK_PUBLISH_BIND=0.0.0.0
|
|
||||||
```
|
`Operation` описывает:
|
||||||
|
|
||||||
Проверить состояние контейнеров:
|
- внешний протокол;
|
||||||
|
- целевой endpoint или метод;
|
||||||
```bash
|
- входную схему;
|
||||||
docker compose ps
|
- правила маппинга входных данных;
|
||||||
```
|
- параметры выполнения;
|
||||||
|
- правила маппинга выходных данных;
|
||||||
Посмотреть журналы:
|
- метаданные MCP tool.
|
||||||
|
|
||||||
```bash
|
`Agent` собирает ограниченный набор опубликованных операций в одну MCP-поверхность. Именно это решает проблему, когда один агент теряется в слишком большом наборе tools.
|
||||||
docker compose logs -f admin-api mcp-server ui
|
|
||||||
```
|
## CI/CD статус
|
||||||
|
|
||||||
Остановить сервис:
|
В репозитории настроены:
|
||||||
|
|
||||||
```bash
|
- `CI` для Rust, UI и deployment manifests;
|
||||||
docker compose down
|
- `CD`, который на `push` в `main` собирает versioned images, пушит их в registry Gitea и деплоит Community через `deploy/community/docker-compose.yml`;
|
||||||
```
|
- tag-based release workflow для сборки release bundle и versioned images;
|
||||||
|
- containerized Community deployment через `deploy/community/docker-compose.yml`.
|
||||||
Обновить Crank до свежих образов:
|
|
||||||
|
Важно:
|
||||||
```bash
|
|
||||||
docker compose --profile local-db pull
|
- workflows лежат в `.gitea/workflows`;
|
||||||
docker compose --profile local-db up -d
|
- Gitea Actions в этом репозитории рассчитаны только на `self-hosted` runner;
|
||||||
```
|
- Gitea secrets содержат только AppRole-доступ к OpenBao: `BAO_ADDR`, `BAO_ROLE_ID`, `BAO_SECRET_ID`;
|
||||||
|
- внешние GitHub-specific механики вроде `workflow_run`, `actions/upload-artifact`, `softprops/action-gh-release` и `ghcr.io` intentionally не используются.
|
||||||
## Запуск с внешним PostgreSQL
|
|
||||||
|
## Поддерживаемые протоколы
|
||||||
Если PostgreSQL уже запущен отдельно, профиль `local-db` не нужен.
|
|
||||||
|
В целевой модели платформа ориентируется на:
|
||||||
В `.env` укажите параметры вашей базы:
|
|
||||||
|
- REST
|
||||||
- `POSTGRES_HOST`;
|
- GraphQL
|
||||||
- `POSTGRES_PORT`;
|
- gRPC
|
||||||
- `POSTGRES_DB`;
|
- WebSocket
|
||||||
- `POSTGRES_USER`;
|
- SOAP
|
||||||
- `POSTGRES_PASSWORD`.
|
|
||||||
|
Все пять протокольных семейств входят в целевой product scope. Разница только в очередности реализации.
|
||||||
Затем запустите только приложения Crank:
|
|
||||||
|
## Frontend e2e
|
||||||
```bash
|
|
||||||
docker compose up -d
|
Для UI настроен Playwright-контур, который поднимает локальный стек:
|
||||||
```
|
|
||||||
|
- `postgres` в отдельном Docker-контейнере;
|
||||||
Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`.
|
- `admin-api` и `mcp-server` через `cargo run`;
|
||||||
|
- `apps/ui` через локальный Node static+proxy server для e2e;
|
||||||
## Запуск из исходников
|
- `CRANK_DEMO_SEED=true` для предсказуемых demo-данных.
|
||||||
|
|
||||||
Если нужно собрать образы самостоятельно, клонируйте репозиторий и используйте корневой [`docker-compose.yml`](./docker-compose.yml):
|
Локальный запуск:
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/bsodfather/crank-community.git crank
|
|
||||||
cd crank
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот вариант удобен для локальной проверки изменений перед отправкой патча.
|
|
||||||
|
|
||||||
## Как пользоваться
|
|
||||||
|
|
||||||
Обычный сценарий:
|
|
||||||
|
|
||||||
1. Зайти в веб-интерфейс.
|
|
||||||
2. Создать REST-инструмент.
|
|
||||||
3. Описать входные параметры и правила вызова REST API.
|
|
||||||
4. Выполнить пробный запрос.
|
|
||||||
5. Опубликовать инструмент.
|
|
||||||
6. Привязать инструмент к агенту.
|
|
||||||
7. Создать ключ агента.
|
|
||||||
8. Подключить MCP-клиент к адресу агента.
|
|
||||||
|
|
||||||
Каждый агент имеет свой каталог инструментов. MCP-клиент видит только те REST-инструменты, которые явно привязаны к этому агенту.
|
|
||||||
|
|
||||||
Рекомендации по названиям, описаниям, схемам, ошибкам и опасным операциям описаны в документе [Проектирование MCP-инструментов](./docs/tool-design.md).
|
|
||||||
|
|
||||||
## Что входит в эту версию
|
|
||||||
|
|
||||||
Этот репозиторий содержит открытую версию Crank:
|
|
||||||
|
|
||||||
- REST API как источник инструментов;
|
|
||||||
- MCP через Streamable HTTP;
|
|
||||||
- веб-интерфейс администратора;
|
|
||||||
- простая авторизация администратора;
|
|
||||||
- ключи агентов для доступа к MCP;
|
|
||||||
- PostgreSQL как основное хранилище;
|
|
||||||
- необязательный Valkey или Redis для служебного кэша.
|
|
||||||
|
|
||||||
Текущие и целевые возможности фиксируются в
|
|
||||||
[`docs/capability-inventory.json`](docs/capability-inventory.json). Статус
|
|
||||||
`implemented` означает реализованный flow; `planned`, `gap` и `blocked` не
|
|
||||||
считаются готовностью. MCP Resources, Prompts, фоновые Tasks и встроенные Load
|
|
||||||
Runs сейчас перечислены только как planned target и не выдаются за работающие
|
|
||||||
возможности этой версии.
|
|
||||||
|
|
||||||
Проверенный snapshot текущих UI, Admin API и MCP flows находится в
|
|
||||||
[`docs/capability-baseline/manifest.json`](docs/capability-baseline/manifest.json).
|
|
||||||
Он связывает inventory, обязательные surfaces, taxonomy, manual checklist и
|
|
||||||
sanitized results точными SHA-256. Полный baseline pass требует сочетания
|
|
||||||
`implemented + automated + pass`; skipped, flaky, not-run и manual-only не
|
|
||||||
становятся pass.
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/
|
|
||||||
admin-api/ HTTP API для веб-интерфейса
|
|
||||||
mcp-server/ MCP-сервер
|
|
||||||
ui/ веб-интерфейс
|
|
||||||
|
|
||||||
crates/
|
|
||||||
crank-core/ общая модель данных
|
|
||||||
crank-registry/ работа с PostgreSQL
|
|
||||||
crank-runtime/ выполнение REST-инструментов
|
|
||||||
crank-adapter-rest/ REST-адаптер
|
|
||||||
crank-community-auth/ пароли и сессии
|
|
||||||
crank-community-mcp/ слой MCP
|
|
||||||
crank-mapping/ преобразование данных через JSONPath
|
|
||||||
crank-schema/ проверка схем
|
|
||||||
|
|
||||||
deploy/community/
|
|
||||||
docker-compose.yml запуск с внешним PostgreSQL
|
|
||||||
docker-compose.images.yml запуск готовых образов без исходников
|
|
||||||
.env.example пример настроек для серверного запуска из исходников
|
|
||||||
.env.images.example пример настроек для запуска готовых образов
|
|
||||||
```
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
Для разработки нужны:
|
|
||||||
|
|
||||||
- Rust toolchain из [`rust-toolchain.toml`](./rust-toolchain.toml);
|
|
||||||
- Node.js и npm;
|
|
||||||
- PostgreSQL, если запускаете сервисы вручную;
|
|
||||||
- Docker для полного стенда и Rust-тестов с временной PostgreSQL.
|
|
||||||
|
|
||||||
Быстрее всего поднять окружение так же, как для обычного запуска:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/bsodfather/crank-community.git crank
|
|
||||||
cd crank
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
Приложения читают настройки из переменных окружения. Можно использовать `.env` через свое окружение разработки, `direnv`, IDE или любой другой привычный способ загрузки переменных.
|
|
||||||
|
|
||||||
Rust-тесты сами поднимают временный PostgreSQL через Testcontainers. Отдельно запускать тестовую БД или задавать URL тестовой базы не нужно.
|
|
||||||
|
|
||||||
Сервер панели управления:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p admin-api
|
|
||||||
```
|
|
||||||
|
|
||||||
MCP-сервер:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
Веб-интерфейс:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/ui
|
|
||||||
npm ci
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just fmt-check
|
|
||||||
just clippy
|
|
||||||
just test
|
|
||||||
```
|
|
||||||
|
|
||||||
Сборка веб-интерфейса:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ui
|
cd apps/ui
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run e2e:install
|
||||||
|
npm run e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
E2E-проверки:
|
Или через `just`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ui
|
just ui-e2e
|
||||||
npx playwright test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Документация
|
Для post-deploy smoke:
|
||||||
|
|
||||||
- [Документация](docs/README.md)
|
```bash
|
||||||
- [Введение](docs/intro.md)
|
just staging-smoke https://<domain>
|
||||||
- [Установка](docs/installation.md)
|
```
|
||||||
- [Первый инструмент](docs/quickstart.md)
|
|
||||||
- [Веб-интерфейс](docs/ui.md)
|
|
||||||
- [MCP-интерфейс](docs/mcp-interface.md)
|
|
||||||
- [Admin API](docs/admin-api.md)
|
|
||||||
- [Практические API-примеры](docs/api-examples.md)
|
|
||||||
- [Production checklist](docs/production-checklist.md)
|
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
|
||||||
- [Настройки запуска](docs/runtime-config.md)
|
|
||||||
- [Machine schema runtime-конфигурации](docs/schemas/runtime-config.schema.json)
|
|
||||||
- [Английский README](docs/en/README.md)
|
|
||||||
- [Capability Inventory](docs/capability-inventory.json)
|
|
||||||
- [Capability Baseline](docs/capability-baseline/manifest.json)
|
|
||||||
|
|
||||||
## Участие в разработке
|
Для browser-authenticated smoke на реальном стенде:
|
||||||
|
|
||||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](CLA.md).
|
```bash
|
||||||
|
export CRANK_STAGING_ADMIN_EMAIL=owner@example.com
|
||||||
|
export CRANK_STAGING_ADMIN_PASSWORD=secret
|
||||||
|
just authenticated-staging-smoke https://<domain>
|
||||||
|
```
|
||||||
|
|
||||||
Правила участия описаны в [CONTRIBUTING.md](CONTRIBUTING.md).
|
Чтобы быстро подготовить запись для `docs/staging-regression-notes.md`:
|
||||||
|
|
||||||
## Лицензия
|
```bash
|
||||||
|
just staging-note-block <domain> <deploy-sha> "codex + operator"
|
||||||
GNU Affero General Public License v3.0 only
|
```
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# TASKS
|
||||||
|
|
||||||
|
## Current
|
||||||
|
|
||||||
|
### `feat/community-finalization`
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- довести `crank-community` до окончательного самостоятельного состояния как открытой `REST-only` редакции;
|
||||||
|
- убрать transitional split-логику, legacy premium ballast и скрытые assumptions из эпохи общего репозитория.
|
||||||
|
|
||||||
|
Main code areas:
|
||||||
|
- `TASKS.md`
|
||||||
|
- `docs/community-source-whitelist.md`
|
||||||
|
- `docs/implementation-plan.md`
|
||||||
|
- `docs/repository-split-map.md`
|
||||||
|
- `docs/community-release-checklist.md`
|
||||||
|
- `docs/commercial-boundaries.md`
|
||||||
|
- workspace manifests
|
||||||
|
- Community backend/UI tests and fixtures
|
||||||
|
- Community UI copy and localization
|
||||||
|
|
||||||
|
Implementation slices:
|
||||||
|
1. привести документацию и backlog к реальному состоянию `crank-community`;
|
||||||
|
2. убрать premium зависимости из Community workspace manifests;
|
||||||
|
3. очистить Community test boundary и убрать зависимость от `test = false` как способа скрывать legacy tests;
|
||||||
|
4. удалить premium backend ballast, который больше не относится к Community;
|
||||||
|
5. дочистить UI, локализацию и e2e fixtures до честного Community surface;
|
||||||
|
6. пройти финальную верификацию Community release path.
|
||||||
|
|
||||||
|
DoD:
|
||||||
|
- `crank-community` описывает себя как самостоятельный public repository, а не как промежуточный этап split;
|
||||||
|
- Community manifests и workspace dependencies соответствуют `REST-only` product boundary;
|
||||||
|
- Community tests и fixtures проверяют только Community functionality;
|
||||||
|
- UI и docs не содержат рабочих premium flows и misleading copy;
|
||||||
|
- Community release path воспроизводим и проверяем без ссылок на transitional import procedure.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- docs consistency pass;
|
||||||
|
- `cargo metadata --no-deps`;
|
||||||
|
- `just fmt`
|
||||||
|
- `just check`
|
||||||
|
- `just test`
|
||||||
|
- UI build and Community-targeted smoke/e2e pass.
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
- done:
|
||||||
|
- `deploy/community/*` already acts as the canonical Community delivery contour
|
||||||
|
- Community capability model is already constrained to:
|
||||||
|
- `REST`
|
||||||
|
- static agent key
|
||||||
|
- `security_level = standard`
|
||||||
|
- public wizard HTML surface is already reduced to the Community `REST` flow
|
||||||
|
- transitional docs and backlog now describe `crank-community` as the current source of truth instead of a future split artifact
|
||||||
|
- premium protocol toolchain dependencies have been removed from the Community workspace manifest while keeping the `REST-only` workspace green under `cargo check`
|
||||||
|
- Community backend crates no longer rely on `test = false`; `just test` now runs against a real Community-only test surface on the isolated local test database
|
||||||
|
- premium protocol and streaming ballast has been removed from Community backend test/dev paths, and Community demo expectations now match the actual `REST-only` demo seed
|
||||||
|
- dormant premium UI modules, fixtures, and translation strings have been removed, while the remaining Community wizard and admin pages stay green under local Playwright smoke
|
||||||
|
- final Community verification pass completed:
|
||||||
|
- `cargo metadata --no-deps`
|
||||||
|
- `just fmt`
|
||||||
|
- `just check`
|
||||||
|
- `just test`
|
||||||
|
- `apps/ui` build
|
||||||
|
- Community-targeted Playwright smoke
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
### `feat/community-release-hardening`
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- закрепить воспроизводимый Community release path и release checklist без скрытых переходных допущений.
|
||||||
|
|
||||||
|
Main code areas:
|
||||||
|
- `.github/workflows/ci.yml`
|
||||||
|
- `.github/workflows/deploy.yml`
|
||||||
|
- `deploy/community/*`
|
||||||
|
- `docs/community-release-checklist.md`
|
||||||
|
- `docs/deploy-and-staging-smoke.md`
|
||||||
|
- `docs/authenticated-staging-pass.md`
|
||||||
|
- `docs/public-smoke-targets.md`
|
||||||
|
- `docs/staging-regression-notes.md`
|
||||||
|
- `scripts/staging-smoke.sh`
|
||||||
|
- `scripts/authenticated-staging-smoke.sh`
|
||||||
|
- `scripts/staging-note-block.sh`
|
||||||
|
|
||||||
|
Implementation slices:
|
||||||
|
1. проверить соответствие CI Community manifests;
|
||||||
|
2. зафиксировать Community-only smoke baseline;
|
||||||
|
3. обновить release checklist после финальной cleanup-фазы.
|
||||||
|
|
||||||
|
DoD:
|
||||||
|
- Community release path не зависит от private repositories;
|
||||||
|
- checklist соответствует реальному Community deploy surface;
|
||||||
|
- пост-деплойная проверка повторяема.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `python3` YAML parse for `.github/workflows/ci.yml`
|
||||||
|
- `docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q`
|
||||||
|
- `docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example --profile cache config -q`
|
||||||
|
- `just check`
|
||||||
|
- `apps/ui` build
|
||||||
|
- Community-targeted Playwright smoke including `staging-authenticated.spec.js`
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
- done:
|
||||||
|
- `UI Checks` in `CI` now run real `npm ci` and `npm run build` before the container image build
|
||||||
|
- Community release docs and helper scripts now describe only the real `REST-only` deploy smoke baseline
|
||||||
|
- staging templates and regression notes no longer require `GraphQL`, `gRPC`, `WebSocket`, `SOAP`, or streaming scenarios in Community
|
||||||
|
- Community public smoke targets are reduced to the canonical `REST -> Open-Meteo` path
|
||||||
|
- obsolete split-helper artifact `scripts/export-community.sh` and stale premium smoke payloads have been removed from the public repo
|
||||||
|
|
||||||
|
### `feat/common-public-improvements`
|
||||||
|
|
||||||
|
Status: in_progress
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- вносить общие улучшения в публичную базу, которые потом синхронно переносятся в `enterprise` и `cloud`.
|
||||||
|
- начать Wave 1 modularization из `modular_decomposition.xml` без изменения поведения Community runtime.
|
||||||
|
|
||||||
|
Main code areas:
|
||||||
|
- общий Community runtime/UI/API surface
|
||||||
|
- `crates/crank-core`
|
||||||
|
- `apps/mcp-server`
|
||||||
|
- `docs/modular_decomposition.xml`
|
||||||
|
|
||||||
|
Implementation slices:
|
||||||
|
1. выполнять общие изменения сначала в `crank-community`;
|
||||||
|
2. брать архитектурные slices из `modular_decomposition.xml`, а не из legacy cleanup backlog;
|
||||||
|
3. после стабилизации базовой границы подтягивать ее в private repos по зафиксированной sync model.
|
||||||
|
|
||||||
|
DoD:
|
||||||
|
- общий функционал не расходится между тремя редакциями без необходимости;
|
||||||
|
- Community остается source base для общей open-core логики.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `cargo check --workspace`
|
||||||
|
- таргетные compile/test checks для затронутого бинаря или crate
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
- done:
|
||||||
|
- Phase 0 / task `0.1`: создан `crank-core::ext` module skeleton
|
||||||
|
- Phase 0 / task `0.2`: `MachineCredentialVerifier` и связанные типы вынесены из `apps/mcp-server` в public seam `crank_core::ext::auth`
|
||||||
|
- Phase 0 / task `0.3`: введены `MachineTokenIssuer`, `NoMachineTokenIssuer`, `TokenIssuerActor` и `TokenIssuerError` в public seam `crank_core::ext::auth`
|
||||||
|
- Phase 0 / task `0.4`: введены `IdentityProvider`, `IdentityProviderKind`, `IdentityError`, `LoginPayload`, `LoginOutcome` и public forwarder type `AuthenticatedIdentity` в `crank_core::ext::auth`
|
||||||
|
- Phase 0 / task `0.5`: введены `PolicyEngine`, `SessionActor`, `PolicyAction`, `PolicyScope`, `PolicyDecision` и default `OwnerOnlyPolicyEngine` в public seam `crank_core::ext::access`
|
||||||
|
- Phase 0 / task `0.6`: введены `AuditSink`, `NoopAuditSink`, `AuditEventId` и базовые audit-типы в public seam `crank_core::ext::audit`
|
||||||
|
- Phase 0 / task `0.7`: введены `CapabilityProfile` и `CommunityCapabilityProfile`, а `admin-api` capability payload теперь собирается через public seam вместо локального literal
|
||||||
|
- Phase 0 / task `0.8`: введены `ProtocolAdapter`, `ProtocolAdapterError`, `AdapterRegistry` и базовые transport-типы (`PreparedRequest`, `AdapterResponse`, `WindowExecutionResult`, `RuntimeRequestContext`) в `crank_core::ext::protocol`
|
||||||
|
- Phase 0 / task `0.9`: `crank-adapter-rest::RestAdapter` реализует новый `ProtocolAdapter` contract, а seam дополнен явным `Target` и window parameters для реального adapter wiring
|
||||||
|
- Phase 0 / task `0.10`: `RuntimeExecutor` переведен с hardcoded adapter fields на `AdapterRegistry`, `crank-runtime` больше не держит protocol feature flags, а общий `PreparedRequest` seam дополнен `timeout_ms` для корректного runtime dispatch
|
||||||
|
- Phase 0 / task `0.11`: добавлены `RuntimeExecutorBuilder` и `community_default()`, а default community runtime wiring вынесен из `RuntimeExecutor` в отдельный builder layer
|
||||||
|
- Phase 0 / task `0.12`: `apps/admin-api` и `apps/mcp-server` переведены на `community_default().with_limits(...).with_response_cache(...).build()` вместо прямой сборки runtime через `RuntimeExecutor::with_limits(...)`
|
||||||
|
- Phase 0 / task `0.13`: введены `RegistryExtension`, `ExtensionMigration` и `apply_extension_migrations(...)` в `crank-registry` как отдельный public seam для additive private migrations
|
||||||
|
- Phase 0 / task `0.14`: добавлены `AdminServiceBuilder`, seam slots (`identity_provider`, `policy_engine`, `audit_sink`, `token_issuer`, `capability_profile`) и community defaults для них; `apps/admin-api/src/main.rs` переведен на builder
|
||||||
|
- Phase 0 / task `0.15`: route delegation переведен на public seams — `capabilities` route идет через `capability_profile`, write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`, а `machine_auth` route использует `token_issuer` seam и сохраняет текущий Community `403` contract
|
||||||
|
- Phase 0 / task `0.16`: phase verification gate пройден — `just fmt`, `just check`, `just test`, таргетные Community machine-auth tests и MCP initialize smoke (`requires_initialized_notification_before_tool_methods`) зеленые; runtime regression test обновлен под новый `RuntimeError::ProtocolAdapter` contract
|
||||||
|
- Phase 1 / tasks `1.1`–`1.3`: strict-REST Community cleanup уже был закрыт ранее — non-REST adapters и `crank-proto` удалены из workspace, descriptor/premium UI surface убран из Community build, wizard и i18n приведены к REST-only baseline
|
||||||
|
- Phase 1 / task `1.4`: создан crate `crank-community-auth`; в него вынесены password hashing/session cookie primitives и `PasswordIdentityProvider`, `admin-api` переключен на этот crate, а `login()` теперь реально делегирует credential check через `IdentityProvider` seam
|
||||||
|
- Phase 1 / task `1.5`: создан crate `crank-community-mcp`; в него вынесены `build_app`, `catalog`, `jsonrpc`, `session` и `auth` из `apps/mcp-server`, а `apps/mcp-server/src/main.rs` стал thin launcher с env parsing и DI wiring
|
||||||
|
- Phase 1 / task `1.6`: Community test surface уже приведен к честному baseline — `test = false` удален ранее, premium-only tests вычищены, `just verify` проходит на текущем составе workspace
|
||||||
|
- Phase 1 / task `1.7`: workspace version bumped to `0.2.0`; release gate пройден (`just verify`, `npm run build`, `npm run e2e`), release commit и tag `v0.2.0` подготовлены в `crank-community`
|
||||||
|
- Phase 2 dependency slice: `IdentityProvider` seam widened to cover `SSO/TOTP` with default `NotSupportedForProvider` methods and shared public DTOs for authorize/callback/two-factor flows; workspace version bumped to `0.3.0` for downstream enterprise consumption
|
||||||
|
- Phase 2 dependency slice: `apps/admin-api` now exposes a reusable lib target (`src/lib.rs`), so downstream private repos can depend on `build_app`, `AppState`, `AdminServiceBuilder`, and auth/state modules without copying the Community admin app
|
||||||
|
- Phase 3 dependency slice: added public tenancy seam in `crank-core` — `TenantId`, `TenantResolutionContext`, `TenantController`, `TenancyError`, and `SingleTenantController` — so `cloud` can build hosted tenant routing without bypassing the shared extension model
|
||||||
|
- Phase 3 dependency slice: added public metering seam in `crank-core` — `MeteringEvent`, `MeteringSink`, `SharedMeteringSink`, and `NoopMeteringSink` — so `cloud` can add hosted usage capture without introducing private-only contracts
|
||||||
|
- Phase 3 dependency slice: added public billing seam in `crank-core` — `BillingHook`, `BillingGate`, `BillingError`, `SharedBillingHook`, and `NoopBillingHook` — so `cloud` can layer billing policy without private-only contracts in the base repo
|
||||||
|
- Phase 3 runtime slice: `RuntimeRequestContext` now carries optional metering context (`workspace_id`, `agent_id`, `source`), `RuntimeExecutorBuilder` accepts a `MeteringSink`, and `RuntimeExecutor` emits `MeteringEvent` on invocation completion while Community apps keep using the default `NoopMeteringSink`
|
||||||
|
- Phase 3 dependency slice: added `CacheBackendFactory` in `crank-runtime` with `BuiltinCacheBackendFactory`; unlike the original draft, the seam lives in runtime rather than core to avoid a `crank-core -> crank-runtime` dependency cycle around `RuntimeCacheStores`
|
||||||
|
- Phase 5 / task `5.1`: added Community UI overlay foundation — `slot-registry.js`, `overlay-loader.js`, optional `CRANK_UI_OVERLAY_DIR` copy path in the UI build, settings slot mounts, and wizard protocol-card slot mounts; Community build stays noop without any private overlay package
|
||||||
|
- backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено
|
||||||
|
|
||||||
|
### `feat/community-release-pipeline`
|
||||||
|
|
||||||
|
Status: in_progress
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- закрыть `Phase 6.1` и сделать reproducible tag-release flow для `crank-community`.
|
||||||
|
|
||||||
|
Main code areas:
|
||||||
|
- `.github/workflows/release.yml`
|
||||||
|
- `apps/admin-api/Dockerfile`
|
||||||
|
- `apps/mcp-server/Dockerfile`
|
||||||
|
- `apps/ui/Dockerfile`
|
||||||
|
|
||||||
|
Implementation slices:
|
||||||
|
1. build release binaries and UI dist on `push tag v*`;
|
||||||
|
2. publish Community images to `GHCR`;
|
||||||
|
3. attach checksums and SBOM to GitHub Release.
|
||||||
|
|
||||||
|
DoD:
|
||||||
|
- `crank-community` выпускается из собственного repo без private inputs;
|
||||||
|
- tag push `v*` produces GitHub Release artifacts and container images.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `python3` YAML parse for `.github/workflows/release.yml`
|
||||||
@@ -3,35 +3,23 @@ name = "admin-api"
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
publish.workspace = true
|
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "admin-api"
|
name = "admin-api"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "crank-migrate"
|
|
||||||
path = "src/bin/crank-migrate.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1"
|
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
axum-extra.workspace = true
|
axum-extra.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||||
crank-artifacts = { path = "../../crates/crank-artifacts" }
|
|
||||||
crank-config = { path = "../../crates/crank-config" }
|
|
||||||
crank-core = { path = "../../crates/crank-core" }
|
crank-core = { path = "../../crates/crank-core" }
|
||||||
crank-import = { path = "../../crates/crank-import" }
|
|
||||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||||
crank-metrics = { path = "../../crates/crank-metrics" }
|
|
||||||
crank-observability = { path = "../../crates/crank-observability" }
|
|
||||||
crank-registry = { path = "../../crates/crank-registry" }
|
crank-registry = { path = "../../crates/crank-registry" }
|
||||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||||
crank-schema = { path = "../../crates/crank-schema" }
|
crank-schema = { path = "../../crates/crank-schema" }
|
||||||
crank-trace = { path = "../../crates/crank-trace" }
|
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
@@ -40,20 +28,12 @@ sha2.workspace = true
|
|||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "time"] }
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
url.workspace = true
|
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
|
async-trait = "0.1"
|
||||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
|
||||||
metrics.workspace = true
|
|
||||||
metrics-util = "0.20.4"
|
|
||||||
opentelemetry.workspace = true
|
|
||||||
opentelemetry_sdk.workspace = true
|
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
tower.workspace = true
|
|
||||||
tracing-opentelemetry.workspace = true
|
|
||||||
|
|||||||
+46
-15
@@ -1,4 +1,42 @@
|
|||||||
FROM rust:1.96.1-bookworm AS builder
|
FROM rust:1.85-bookworm AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY .sqlx ./.sqlx
|
||||||
|
COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml
|
||||||
|
COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
|
||||||
|
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
||||||
|
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
||||||
|
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
||||||
|
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
||||||
|
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
||||||
|
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
||||||
|
|
||||||
|
RUN mkdir -p \
|
||||||
|
apps/admin-api/src \
|
||||||
|
apps/mcp-server/src \
|
||||||
|
crates/crank-core/src \
|
||||||
|
crates/crank-schema/src \
|
||||||
|
crates/crank-mapping/src \
|
||||||
|
crates/crank-registry/src \
|
||||||
|
crates/crank-runtime/src \
|
||||||
|
crates/crank-adapter-rest/src \
|
||||||
|
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
||||||
|
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
||||||
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
SQLX_OFFLINE=true cargo build --release -p admin-api
|
||||||
|
|
||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,31 +45,24 @@ COPY .sqlx ./.sqlx
|
|||||||
COPY apps ./apps
|
COPY apps ./apps
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
|
|
||||||
RUN --mount=type=cache,id=crank-admin-cargo-registry,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,id=crank-admin-cargo-git,target=/usr/local/cargo/git/db \
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
--mount=type=cache,id=crank-admin-target,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
SQLX_OFFLINE=true cargo build --release -p admin-api \
|
SQLX_OFFLINE=true cargo build --release -p admin-api \
|
||||||
&& cp /app/target/release/admin-api /tmp/admin-api \
|
&& cp /app/target/release/admin-api /tmp/admin-api
|
||||||
&& cp /app/target/release/crank-migrate /tmp/crank-migrate
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
|
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
|
||||||
COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate
|
|
||||||
COPY apps/admin-api/docker-entrypoint.sh /usr/local/bin/crank-admin-entrypoint
|
|
||||||
COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck
|
|
||||||
|
|
||||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
|
||||||
&& chmod 0755 /usr/local/bin/crank-admin-entrypoint /usr/local/bin/crank-http-healthcheck
|
|
||||||
|
|
||||||
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
|
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||||
ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/crank-admin-entrypoint"]
|
|
||||||
CMD ["admin-api"]
|
CMD ["admin-api"]
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# Docker creates a fresh named volume as root:root 0755. The artifact store
|
|
||||||
# deliberately rejects that mode: before starting the API, provision exactly
|
|
||||||
# the private root its pinned-directory checks require.
|
|
||||||
if [ "$#" -gt 0 ] && [ "$1" = "admin-api" ]; then
|
|
||||||
storage_root="${CRANK_STORAGE_ROOT:-/var/lib/crank/storage}"
|
|
||||||
case "$storage_root" in
|
|
||||||
/*) ;;
|
|
||||||
*)
|
|
||||||
echo "CRANK_STORAGE_ROOT must be an absolute path" >&2
|
|
||||||
exit 64
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [ -L "$storage_root" ] || { [ -e "$storage_root" ] && [ ! -d "$storage_root" ]; }; then
|
|
||||||
echo "CRANK_STORAGE_ROOT must be a directory, not a symlink or file" >&2
|
|
||||||
exit 64
|
|
||||||
fi
|
|
||||||
umask 077
|
|
||||||
mkdir -p -- "$storage_root"
|
|
||||||
chmod 0700 -- "$storage_root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
+3807
-76
File diff suppressed because it is too large
Load Diff
+3
-131
@@ -1,24 +1,22 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{OriginalUri, Request, State},
|
extract::{OriginalUri, Request, State},
|
||||||
http::{HeaderValue, Method, header},
|
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||||
use crank_community_auth::{
|
use crank_community_auth::{
|
||||||
cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token,
|
cleared_session_cookie as build_cleared_session_cookie,
|
||||||
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
||||||
session_cookie as build_session_cookie_header,
|
session_cookie as build_session_cookie_header,
|
||||||
};
|
};
|
||||||
use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId};
|
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||||
use crank_registry::WorkspaceMembershipRecord;
|
use crank_registry::WorkspaceMembershipRecord;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
pub use crank_community_auth::{
|
pub use crank_community_auth::{
|
||||||
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
|
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
|
||||||
hash_session_secret, verify_password,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -55,10 +53,6 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
|
|||||||
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
|
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_csrf_token_value() -> String {
|
|
||||||
build_csrf_token()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
||||||
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
||||||
}
|
}
|
||||||
@@ -96,81 +90,11 @@ pub async fn require_workspace_session(
|
|||||||
if !has_access {
|
if !has_access {
|
||||||
return Err(ApiError::forbidden("workspace access denied"));
|
return Err(ApiError::forbidden("workspace access denied"));
|
||||||
}
|
}
|
||||||
if !matches!(
|
|
||||||
request.method(),
|
|
||||||
&axum::http::Method::GET | &axum::http::Method::HEAD
|
|
||||||
) && !session.memberships.iter().any(|membership| {
|
|
||||||
membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner
|
|
||||||
}) {
|
|
||||||
return Err(ApiError::forbidden("workspace owner access required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
request.extensions_mut().insert(session);
|
request.extensions_mut().insert(session);
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn require_csrf_for_browser_mutations(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
if !requires_csrf(request.method(), request.uri().path()) {
|
|
||||||
return Ok(next.run(request).await);
|
|
||||||
}
|
|
||||||
let (session_id, _session_value) = extract_session_token(&jar)
|
|
||||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
|
||||||
let token = request
|
|
||||||
.headers()
|
|
||||||
.get("x-csrf-token")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.filter(|value| valid_csrf_token(value))
|
|
||||||
.ok_or_else(|| ApiError::forbidden("csrf validation failed"))?;
|
|
||||||
let csrf_hash = hash_csrf_token(
|
|
||||||
&session_id,
|
|
||||||
token,
|
|
||||||
&state.service.auth_settings().session_secret,
|
|
||||||
);
|
|
||||||
if !state
|
|
||||||
.service
|
|
||||||
.verify_session_csrf(&session_id, &csrf_hash)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Err(ApiError::forbidden("csrf validation failed"));
|
|
||||||
}
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn enforce_browser_security(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
if let Some(origin) = request.headers().get(header::ORIGIN)
|
|
||||||
&& is_cross_origin(
|
|
||||||
origin,
|
|
||||||
request.headers().get(header::HOST),
|
|
||||||
if state.service.auth_settings().cookie_secure {
|
|
||||||
"https"
|
|
||||||
} else {
|
|
||||||
"http"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
&& request.uri().path().starts_with("/api/")
|
|
||||||
{
|
|
||||||
return Err(ApiError::forbidden("cross-origin admin request denied"));
|
|
||||||
}
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
let headers = response.headers_mut();
|
|
||||||
headers.insert(
|
|
||||||
"x-content-type-options",
|
|
||||||
HeaderValue::from_static("nosniff"),
|
|
||||||
);
|
|
||||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
|
||||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_authenticated_session(
|
async fn resolve_authenticated_session(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
@@ -198,55 +122,3 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
|||||||
|
|
||||||
Some(WorkspaceId::new(workspace_id))
|
Some(WorkspaceId::new(workspace_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn requires_csrf(method: &Method, path: &str) -> bool {
|
|
||||||
if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
path,
|
|
||||||
"/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf"
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
path.starts_with("/api/auth/") || path.starts_with("/api/admin/")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_csrf_token(value: &str) -> bool {
|
|
||||||
(32..=256).contains(&value.len())
|
|
||||||
&& value
|
|
||||||
.bytes()
|
|
||||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_cross_origin(
|
|
||||||
origin: &HeaderValue,
|
|
||||||
host: Option<&HeaderValue>,
|
|
||||||
expected_scheme: &str,
|
|
||||||
) -> bool {
|
|
||||||
let Some(host) = host.and_then(|value| value.to_str().ok()) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Some(origin) = origin.to_str().ok() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Ok(url) = url::Url::parse(origin) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
url.host_str()
|
|
||||||
.zip(url.port_or_known_default())
|
|
||||||
.map(|(origin_host, origin_port)| {
|
|
||||||
let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme());
|
|
||||||
let request_authority = if host.contains(':') {
|
|
||||||
format!("{expected_scheme}://{}", host.to_ascii_lowercase())
|
|
||||||
} else {
|
|
||||||
let default_port = if expected_scheme == "https" { 443 } else { 80 };
|
|
||||||
format!(
|
|
||||||
"{expected_scheme}://{}:{default_port}",
|
|
||||||
host.to_ascii_lowercase()
|
|
||||||
)
|
|
||||||
};
|
|
||||||
origin_authority.to_ascii_lowercase() != request_authority
|
|
||||||
})
|
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,952 +0,0 @@
|
|||||||
#[path = "crank_migrate/admin_auth_command.rs"]
|
|
||||||
mod admin_auth_command;
|
|
||||||
#[path = "crank_migrate/db_connect.rs"]
|
|
||||||
mod db_connect;
|
|
||||||
|
|
||||||
use crank_config::{ConfigSource, parse_migrator};
|
|
||||||
use crank_registry::{
|
|
||||||
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
|
|
||||||
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
|
|
||||||
PostgresRegistry, RegistryError, SecretVersionRecord,
|
|
||||||
};
|
|
||||||
use crank_runtime::SecretCrypto;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::{path::Path, process::ExitCode};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000;
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> ExitCode {
|
|
||||||
match run().await {
|
|
||||||
Ok(code) => code,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": error.code,
|
|
||||||
"stage": error.stage,
|
|
||||||
"version": error.version,
|
|
||||||
"recovery": error.recovery,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct CliError {
|
|
||||||
code: &'static str,
|
|
||||||
stage: &'static str,
|
|
||||||
recovery: &'static str,
|
|
||||||
version: Option<i64>,
|
|
||||||
}
|
|
||||||
impl CliError {
|
|
||||||
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
|
|
||||||
Self {
|
|
||||||
code,
|
|
||||||
stage,
|
|
||||||
recovery,
|
|
||||||
version: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn from_migration(error: crank_registry::MigrationError) -> Self {
|
|
||||||
Self {
|
|
||||||
code: error.code(),
|
|
||||||
stage: error.stage(),
|
|
||||||
recovery: error.recovery(),
|
|
||||||
version: error.version(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn from_registry(error: RegistryError) -> Self {
|
|
||||||
match error {
|
|
||||||
RegistryError::MasterKeyIdentityMismatch { .. } => Self::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_master_key",
|
|
||||||
),
|
|
||||||
RegistryError::InvalidMasterKeyIdentity => Self::new(
|
|
||||||
"master_key_identity_invalid",
|
|
||||||
"master_key.identity",
|
|
||||||
"verify_operator_input",
|
|
||||||
),
|
|
||||||
RegistryError::MasterKeyRotationInProgress => Self::new(
|
|
||||||
"master_key_rotation_in_progress",
|
|
||||||
"master_key.rotation",
|
|
||||||
"resume_verify_promote_or_abort_rotation",
|
|
||||||
),
|
|
||||||
RegistryError::MasterKeyRotationNotFound { .. } => Self::new(
|
|
||||||
"master_key_rotation_not_found",
|
|
||||||
"master_key.rotation",
|
|
||||||
"run_status",
|
|
||||||
),
|
|
||||||
RegistryError::MasterKeyRotationConflict => Self::new(
|
|
||||||
"master_key_rotation_conflict",
|
|
||||||
"master_key.rotation",
|
|
||||||
"run_status",
|
|
||||||
),
|
|
||||||
RegistryError::MasterKeyRotationVerificationFailed => Self::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
),
|
|
||||||
RegistryError::AdminBootstrapUnavailable => Self::new(
|
|
||||||
"admin_bootstrap_unavailable",
|
|
||||||
"admin_auth.bootstrap",
|
|
||||||
"use_existing_active_contract_or_wait_until_expired",
|
|
||||||
),
|
|
||||||
RegistryError::AdminBootstrapRejected => Self::new(
|
|
||||||
"admin_bootstrap_rejected",
|
|
||||||
"admin_auth.bootstrap",
|
|
||||||
"create_new_local_bootstrap_contract",
|
|
||||||
),
|
|
||||||
RegistryError::AdminRecoveryRejected => Self::new(
|
|
||||||
"admin_recovery_rejected",
|
|
||||||
"admin_auth.recovery",
|
|
||||||
"verify_local_inputs_and_master_key",
|
|
||||||
),
|
|
||||||
RegistryError::AdminLoginRateLimited { .. } => Self::new(
|
|
||||||
"admin_login_rate_limited",
|
|
||||||
"admin_auth.login",
|
|
||||||
"retry_after_delay",
|
|
||||||
),
|
|
||||||
RegistryError::AdminCsrfRejected => {
|
|
||||||
Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session")
|
|
||||||
}
|
|
||||||
RegistryError::Storage(_) => {
|
|
||||||
Self::new("storage_unavailable", "database.query", "contact_operator")
|
|
||||||
}
|
|
||||||
_ => Self::new("registry_error", "registry.operation", "contact_operator"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run() -> Result<ExitCode, CliError> {
|
|
||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
|
||||||
if args.first().map(String::as_str) == Some("master-key") {
|
|
||||||
return run_master_key(&args[1..]).await;
|
|
||||||
}
|
|
||||||
if args.first().map(String::as_str) == Some("admin-auth") {
|
|
||||||
return admin_auth_command::run_admin_auth(&args[1..]).await;
|
|
||||||
}
|
|
||||||
let requested = args.first().map(String::as_str);
|
|
||||||
let option = args.get(1).map(String::as_str);
|
|
||||||
if args.len() > 2 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_preflight",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let command = match requested {
|
|
||||||
None | Some("preflight") => "preflight",
|
|
||||||
Some("plan") => "plan",
|
|
||||||
Some("apply") => "apply",
|
|
||||||
Some(_) => {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_preflight",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if command == "plan" {
|
|
||||||
MigrationAuthority::validate_sequence().map_err(CliError::from_migration)?;
|
|
||||||
let sequence = MigrationAuthority::sequence()
|
|
||||||
.into_iter()
|
|
||||||
.map(|migration| {
|
|
||||||
let backfill = match migration.backfill {
|
|
||||||
BackfillPolicy::None => json!({ "kind": "none" }),
|
|
||||||
BackfillPolicy::Bounded {
|
|
||||||
max_batch_rows,
|
|
||||||
max_batch_ms,
|
|
||||||
resumable,
|
|
||||||
} => json!({
|
|
||||||
"kind": "bounded",
|
|
||||||
"max_batch_rows": max_batch_rows,
|
|
||||||
"max_batch_ms": max_batch_ms,
|
|
||||||
"resumable": resumable,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
json!({
|
|
||||||
"version": migration.version,
|
|
||||||
"name": migration.name,
|
|
||||||
"checksum": migration.checksum,
|
|
||||||
"source_digest": migration.source_digest,
|
|
||||||
"phase": migration.phase,
|
|
||||||
"compatibility": migration.compatibility,
|
|
||||||
"owner": migration.owner,
|
|
||||||
"transactional": migration.transactional,
|
|
||||||
"backfill": backfill,
|
|
||||||
"readable_schema_min": migration.readable_schema_min,
|
|
||||||
"readable_schema_max": migration.readable_schema_max,
|
|
||||||
"contract_evidence": migration.contract_evidence,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let plan = json!({ "schema_version": 1, "sequence": sequence });
|
|
||||||
if option == Some("--check") {
|
|
||||||
let bytes = std::fs::read("docs/schemas/migration-sequence.json")
|
|
||||||
.map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?;
|
|
||||||
if bytes.len() > 65_536 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"contract_drift",
|
|
||||||
"plan.size",
|
|
||||||
"contact_operator",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let committed: serde_json::Value = serde_json::from_slice(&bytes)
|
|
||||||
.map_err(|_| CliError::new("contract_drift", "plan.parse", "contact_operator"))?;
|
|
||||||
if committed != plan {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"contract_drift",
|
|
||||||
"plan.compare",
|
|
||||||
"contact_operator",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
println!("{}", json!({ "status": "contract_current" }));
|
|
||||||
return Ok(ExitCode::SUCCESS);
|
|
||||||
}
|
|
||||||
if option.is_some() {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_preflight",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
println!("{plan}");
|
|
||||||
return Ok(ExitCode::SUCCESS);
|
|
||||||
}
|
|
||||||
if option.is_some() {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_preflight",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = parse_migrator(
|
|
||||||
ConfigSource::from_os_for_migrator()
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
|
||||||
)
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
|
||||||
let pool = db_connect::connect(&config.database).await?;
|
|
||||||
|
|
||||||
if command == "apply" {
|
|
||||||
let result = MigrationAuthority::apply(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_migration)?;
|
|
||||||
let (status, from, to) = match result {
|
|
||||||
MigrationApplyResult::Applied { from, to } => ("applied", from, to),
|
|
||||||
MigrationApplyResult::AlreadyCurrent { version } => {
|
|
||||||
("already_current", version, version)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({ "status": status, "from_version": from, "to_version": to })
|
|
||||||
);
|
|
||||||
return Ok(ExitCode::SUCCESS);
|
|
||||||
}
|
|
||||||
|
|
||||||
match MigrationAuthority::preflight(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_migration)?
|
|
||||||
{
|
|
||||||
MigrationPreflight::Current { version } => {
|
|
||||||
println!("{}", json!({ "status": "current", "version": version }));
|
|
||||||
Ok(ExitCode::SUCCESS)
|
|
||||||
}
|
|
||||||
MigrationPreflight::MigrationRequired { current, target } => {
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "migration_required",
|
|
||||||
"current_version": current,
|
|
||||||
"target_version": target,
|
|
||||||
"recovery": "run_controlled_migration",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
Ok(ExitCode::from(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
|
|
||||||
let Some(command) = arguments.first().map(String::as_str) else {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let options = MasterKeyOptions::parse(&arguments[1..])?;
|
|
||||||
let config = parse_migrator(
|
|
||||||
ConfigSource::from_os_for_migrator()
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
|
||||||
)
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
|
||||||
let registry = db_connect::connect_registry(&config.database).await?;
|
|
||||||
|
|
||||||
match command {
|
|
||||||
"status" => {
|
|
||||||
ensure_no_options(&options)?;
|
|
||||||
let status = registry
|
|
||||||
.master_key_rotation_status()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "ok",
|
|
||||||
"active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch),
|
|
||||||
"rotation_count": status.rotations.len(),
|
|
||||||
"rotations": status.rotations.iter().map(rotation_json).collect::<Vec<_>>(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"preflight" => {
|
|
||||||
let preflight = master_key_preflight(®istry, &options).await?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "preflight_ok",
|
|
||||||
"source_epoch": preflight.source_epoch,
|
|
||||||
"target_epoch": preflight.target_epoch,
|
|
||||||
"affected_secret_versions": preflight.affected_secret_versions,
|
|
||||||
"backup_ref": preflight.backup_ref.map(|_| "configured"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"rotate" => {
|
|
||||||
let (rotation, current_crypto, target_crypto) =
|
|
||||||
if let Some(rotation) = active_rotation(®istry, &["running"]).await? {
|
|
||||||
let (current_crypto, target_crypto) =
|
|
||||||
rotation_crypto_for_resume(®istry, &options, &rotation).await?;
|
|
||||||
(rotation, current_crypto, target_crypto)
|
|
||||||
} else {
|
|
||||||
let preflight = master_key_preflight(®istry, &options).await?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let rotation = registry
|
|
||||||
.begin_master_key_rotation(
|
|
||||||
preflight.source_epoch,
|
|
||||||
preflight.target_epoch,
|
|
||||||
preflight.target_crypto.master_key_fingerprint(),
|
|
||||||
preflight.backup_ref.as_deref(),
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
(rotation, preflight.current_crypto, preflight.target_crypto)
|
|
||||||
};
|
|
||||||
let processed = process_rotation_batch(
|
|
||||||
®istry,
|
|
||||||
&rotation,
|
|
||||||
¤t_crypto,
|
|
||||||
&target_crypto,
|
|
||||||
options.max_versions,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let status = if rotation.processed_secret_versions + processed
|
|
||||||
>= rotation.total_secret_versions
|
|
||||||
{
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
registry
|
|
||||||
.finish_master_key_rotation_batches(&rotation.id, &now)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?
|
|
||||||
} else {
|
|
||||||
registry
|
|
||||||
.master_key_rotation_status()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?
|
|
||||||
.rotations
|
|
||||||
.into_iter()
|
|
||||||
.find(|candidate| candidate.id == rotation.id)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_not_found",
|
|
||||||
"master_key.rotation",
|
|
||||||
"run_status",
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": status.state,
|
|
||||||
"rotation_id": status.id,
|
|
||||||
"source_epoch": status.source_epoch,
|
|
||||||
"target_epoch": status.target_epoch,
|
|
||||||
"processed_secret_versions": status.processed_secret_versions,
|
|
||||||
"total_secret_versions": status.total_secret_versions,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"verify" => {
|
|
||||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
|
||||||
let rotation = active_rotation(®istry, &["verifying"])
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_conflict",
|
|
||||||
"master_key.rotation",
|
|
||||||
"run_rotate",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
|
|
||||||
.map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_target_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let verified = verify_staged_targets(®istry, &rotation, &target_crypto).await?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let status = registry
|
|
||||||
.verify_master_key_rotation(&rotation.id, verified, &now)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": status.state,
|
|
||||||
"rotation_id": status.id,
|
|
||||||
"verified_secret_versions": status.verified_secret_versions,
|
|
||||||
"total_secret_versions": status.total_secret_versions,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"promote" => {
|
|
||||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
|
||||||
let rotation = active_rotation(®istry, &["verified"])
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_conflict",
|
|
||||||
"master_key.rotation",
|
|
||||||
"run_verify",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
|
|
||||||
.map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_target_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
verify_staged_targets(®istry, &rotation, &target_crypto).await?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let status = registry
|
|
||||||
.promote_master_key_rotation(
|
|
||||||
&rotation.id,
|
|
||||||
MasterKeyIdentityCandidate {
|
|
||||||
epoch: rotation.target_epoch,
|
|
||||||
fingerprint: target_crypto.master_key_fingerprint(),
|
|
||||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
|
||||||
observed_at: &now,
|
|
||||||
},
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": status.state,
|
|
||||||
"rotation_id": status.id,
|
|
||||||
"active_epoch": status.target_epoch,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"abort" => {
|
|
||||||
let rotation_id = options
|
|
||||||
.rotation_id
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let status = registry
|
|
||||||
.abort_master_key_rotation(rotation_id, &now)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": status.state,
|
|
||||||
"rotation_id": status.id,
|
|
||||||
"active_epoch": status.source_epoch,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ExitCode::SUCCESS)
|
|
||||||
}
|
|
||||||
#[derive(Default)]
|
|
||||||
struct MasterKeyOptions {
|
|
||||||
current_key_file: Option<String>,
|
|
||||||
target_key_file: Option<String>,
|
|
||||||
backup_ref: Option<String>,
|
|
||||||
rotation_id: Option<String>,
|
|
||||||
max_versions: Option<usize>,
|
|
||||||
}
|
|
||||||
impl MasterKeyOptions {
|
|
||||||
fn parse(arguments: &[String]) -> Result<Self, CliError> {
|
|
||||||
let mut options = Self::default();
|
|
||||||
let mut index = 0;
|
|
||||||
while index < arguments.len() {
|
|
||||||
let key = arguments[index].as_str();
|
|
||||||
let Some(value) = arguments.get(index + 1) else {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match key {
|
|
||||||
"--current-key-file" => options.current_key_file = Some(value.clone()),
|
|
||||||
"--target-key-file" => options.target_key_file = Some(value.clone()),
|
|
||||||
"--backup-ref" => options.backup_ref = Some(value.clone()),
|
|
||||||
"--rotation-id" => options.rotation_id = Some(value.clone()),
|
|
||||||
"--max-versions" => {
|
|
||||||
let parsed = value.parse::<usize>().map_err(|_| {
|
|
||||||
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
|
|
||||||
})?;
|
|
||||||
if parsed == 0 || parsed > 10_000 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
options.max_versions = Some(parsed);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
}
|
|
||||||
Ok(options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MasterKeyPreflight {
|
|
||||||
source_epoch: i64,
|
|
||||||
target_epoch: i64,
|
|
||||||
affected_secret_versions: usize,
|
|
||||||
backup_ref: Option<String>,
|
|
||||||
current_crypto: SecretCrypto,
|
|
||||||
target_crypto: SecretCrypto,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn master_key_preflight(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
options: &MasterKeyOptions,
|
|
||||||
) -> Result<MasterKeyPreflight, CliError> {
|
|
||||||
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
|
|
||||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
|
||||||
let status = registry
|
|
||||||
.master_key_rotation_status()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
if status.rotations.iter().any(|rotation| {
|
|
||||||
matches!(
|
|
||||||
rotation.state.as_str(),
|
|
||||||
"running" | "verifying" | "verified"
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_rotation_in_progress",
|
|
||||||
"master_key.rotation",
|
|
||||||
"resume_verify_promote_or_abort_rotation",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let active = status.active_identity.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_identity_missing",
|
|
||||||
"master_key.identity",
|
|
||||||
"start_secret_process_once",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let current_crypto = SecretCrypto::with_epoch(¤t_key, active.epoch).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if current_crypto.master_key_fingerprint() != active.fingerprint {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_current_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let target_epoch = active.epoch + 1;
|
|
||||||
let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint()
|
|
||||||
|| registry
|
|
||||||
.master_key_fingerprint_exists(target_crypto.master_key_fingerprint())
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?
|
|
||||||
{
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_rotation_conflict",
|
|
||||||
"master_key.rotation",
|
|
||||||
"choose_new_target_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let affected_secret_versions =
|
|
||||||
count_and_verify_current_versions(registry, active.epoch, ¤t_crypto).await?;
|
|
||||||
Ok(MasterKeyPreflight {
|
|
||||||
source_epoch: active.epoch,
|
|
||||||
target_epoch,
|
|
||||||
affected_secret_versions,
|
|
||||||
backup_ref: options.backup_ref.clone(),
|
|
||||||
current_crypto,
|
|
||||||
target_crypto,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rotation_crypto_for_resume(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
options: &MasterKeyOptions,
|
|
||||||
rotation: &MasterKeyRotationRecord,
|
|
||||||
) -> Result<(SecretCrypto, SecretCrypto), CliError> {
|
|
||||||
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
|
|
||||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
|
||||||
let active = registry
|
|
||||||
.active_master_key_identity()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_identity_missing",
|
|
||||||
"master_key.identity",
|
|
||||||
"start_secret_process_once",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let current_crypto =
|
|
||||||
SecretCrypto::with_epoch(¤t_key, rotation.source_epoch).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if active.epoch != rotation.source_epoch
|
|
||||||
|| active.fingerprint != current_crypto.master_key_fingerprint()
|
|
||||||
{
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_current_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let target_crypto =
|
|
||||||
SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_target_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok((current_crypto, target_crypto))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_rotation_batch(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
rotation: &MasterKeyRotationRecord,
|
|
||||||
current_crypto: &SecretCrypto,
|
|
||||||
target_crypto: &SecretCrypto,
|
|
||||||
max_versions: Option<usize>,
|
|
||||||
) -> Result<i64, CliError> {
|
|
||||||
let mut processed = 0_i64;
|
|
||||||
let mut after_secret_id: Option<String> = None;
|
|
||||||
let mut after_version: Option<u32> = None;
|
|
||||||
loop {
|
|
||||||
let versions = registry
|
|
||||||
.list_secret_versions_for_master_key_epoch_page(
|
|
||||||
rotation.source_epoch,
|
|
||||||
after_secret_id.as_deref(),
|
|
||||||
after_version,
|
|
||||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
if versions.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for version in versions {
|
|
||||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
|
||||||
after_version = Some(version.secret_version.version);
|
|
||||||
if version.target_master_key_epoch == Some(rotation.target_epoch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if max_versions.is_some_and(|limit| processed as usize >= limit) {
|
|
||||||
return Ok(processed);
|
|
||||||
}
|
|
||||||
let plaintext = decrypt_current(&version, current_crypto)?;
|
|
||||||
let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
registry
|
|
||||||
.stage_master_key_rotation_ciphertext(
|
|
||||||
&rotation.id,
|
|
||||||
&version.secret_version.secret_id,
|
|
||||||
version.secret_version.version,
|
|
||||||
rotation.source_epoch,
|
|
||||||
&target_ciphertext,
|
|
||||||
target_crypto.key_version(),
|
|
||||||
rotation.target_epoch,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
processed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(processed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify_staged_targets(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
rotation: &MasterKeyRotationRecord,
|
|
||||||
target_crypto: &SecretCrypto,
|
|
||||||
) -> Result<i64, CliError> {
|
|
||||||
let mut verified = 0_i64;
|
|
||||||
let mut after_secret_id: Option<String> = None;
|
|
||||||
let mut after_version: Option<u32> = None;
|
|
||||||
loop {
|
|
||||||
let versions = registry
|
|
||||||
.list_target_secret_versions_for_master_key_rotation_page(
|
|
||||||
rotation.target_epoch,
|
|
||||||
after_secret_id.as_deref(),
|
|
||||||
after_version,
|
|
||||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
if versions.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for version in versions {
|
|
||||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
|
||||||
after_version = Some(version.secret_version.version);
|
|
||||||
let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let key_version = version.target_key_version.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
target_crypto
|
|
||||||
.decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext)
|
|
||||||
.map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
verified += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verified != rotation.total_secret_versions {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(verified)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_and_verify_current_versions(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
epoch: i64,
|
|
||||||
current_crypto: &SecretCrypto,
|
|
||||||
) -> Result<usize, CliError> {
|
|
||||||
let mut count = 0_usize;
|
|
||||||
let mut after_secret_id: Option<String> = None;
|
|
||||||
let mut after_version: Option<u32> = None;
|
|
||||||
loop {
|
|
||||||
let versions = registry
|
|
||||||
.list_secret_versions_for_master_key_epoch_page(
|
|
||||||
epoch,
|
|
||||||
after_secret_id.as_deref(),
|
|
||||||
after_version,
|
|
||||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
if versions.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for version in versions {
|
|
||||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
|
||||||
after_version = Some(version.secret_version.version);
|
|
||||||
decrypt_current(&version, current_crypto)?;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_current(
|
|
||||||
version: &SecretVersionRecord,
|
|
||||||
current_crypto: &SecretCrypto,
|
|
||||||
) -> Result<serde_json::Value, CliError> {
|
|
||||||
current_crypto
|
|
||||||
.decrypt_for_epoch(
|
|
||||||
&version.secret_version.key_version,
|
|
||||||
version.master_key_epoch,
|
|
||||||
&version.secret_version.ciphertext,
|
|
||||||
)
|
|
||||||
.map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_rotation_verification_failed",
|
|
||||||
"master_key.rotation",
|
|
||||||
"rerun_rotation_or_abort",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn active_rotation(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
allowed_states: &[&str],
|
|
||||||
) -> Result<Option<MasterKeyRotationRecord>, CliError> {
|
|
||||||
let status = registry
|
|
||||||
.master_key_rotation_status()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
Ok(status
|
|
||||||
.rotations
|
|
||||||
.into_iter()
|
|
||||||
.find(|rotation| allowed_states.contains(&rotation.state.as_str())))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"id": rotation.id,
|
|
||||||
"source_epoch": rotation.source_epoch,
|
|
||||||
"target_epoch": rotation.target_epoch,
|
|
||||||
"state": rotation.state,
|
|
||||||
"backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"),
|
|
||||||
"checkpoint_secret_id": rotation.checkpoint_secret_id,
|
|
||||||
"total_secret_versions": rotation.total_secret_versions,
|
|
||||||
"processed_secret_versions": rotation.processed_secret_versions,
|
|
||||||
"verified_secret_versions": rotation.verified_secret_versions,
|
|
||||||
"failure_code": rotation.failure_code,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_required_key_file(path: Option<&str>) -> Result<String, CliError> {
|
|
||||||
let path = path.ok_or_else(|| {
|
|
||||||
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
|
|
||||||
})?;
|
|
||||||
if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_input_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let metadata = std::fs::metadata(Path::new(path)).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_input_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !metadata.is_file() || metadata.len() > 16_384 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_input_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let value = std::fs::read_to_string(Path::new(path)).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_input_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_input_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"verify_operator_input",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> {
|
|
||||||
if options.current_key_file.is_some()
|
|
||||||
|| options.target_key_file.is_some()
|
|
||||||
|| options.backup_ref.is_some()
|
|
||||||
|| options.rotation_id.is_some()
|
|
||||||
|| options.max_versions.is_some()
|
|
||||||
{
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_master_key_status",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
||||||
use crank_community_auth::hash_password;
|
|
||||||
use crank_config::{ConfigSource, parse_migrator};
|
|
||||||
use crank_registry::{
|
|
||||||
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry,
|
|
||||||
RecoverAdminPasswordRequest,
|
|
||||||
};
|
|
||||||
use crank_runtime::SecretCrypto;
|
|
||||||
use rand::RngExt;
|
|
||||||
use serde_json::json;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::{
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::ExitCode,
|
|
||||||
};
|
|
||||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
|
||||||
|
|
||||||
use super::{CliError, db_connect::connect_registry};
|
|
||||||
|
|
||||||
pub(super) async fn run_admin_auth(arguments: &[String]) -> Result<ExitCode, CliError> {
|
|
||||||
let Some(command) = arguments.first().map(String::as_str) else {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_admin_auth_bootstrap_create",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let options = AdminAuthOptions::parse(&arguments[1..])?;
|
|
||||||
let config = parse_migrator(
|
|
||||||
ConfigSource::from_os_for_migrator()
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
|
||||||
)
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
|
||||||
let registry = connect_registry(&config.database).await?;
|
|
||||||
match command {
|
|
||||||
"bootstrap-create" => create_bootstrap_contract(®istry, options).await?,
|
|
||||||
"bootstrap-complete" => complete_bootstrap_contract(®istry, options).await?,
|
|
||||||
"recover" => recover_admin_password(®istry, options).await?,
|
|
||||||
_ => {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_admin_auth_bootstrap_create",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ExitCode::SUCCESS)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn complete_bootstrap_contract(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
options: AdminAuthOptions,
|
|
||||||
) -> Result<(), CliError> {
|
|
||||||
let token_path = options
|
|
||||||
.token_file
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?;
|
|
||||||
let password_path = options.password_file.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
|
|
||||||
})?;
|
|
||||||
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"provide_password_pepper_file",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?;
|
|
||||||
let password = read_secret_file(password_path, "admin_auth.password")?;
|
|
||||||
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
|
|
||||||
if !(32..=256).contains(&token.len())
|
|
||||||
|| !(12..=256).contains(&password.len())
|
|
||||||
|| pepper.is_empty()
|
|
||||||
|| pepper.len() > 1_024
|
|
||||||
{
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"provide_bounded_secret_files",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token_hash = admin_auth_hash("bootstrap", &token);
|
|
||||||
let password_hash = hash_password(&password, &pepper).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"admin_bootstrap_rejected",
|
|
||||||
"admin_auth.bootstrap",
|
|
||||||
"verify_local_inputs",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let user_id = registry
|
|
||||||
.consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest {
|
|
||||||
token_hash: &token_hash,
|
|
||||||
password_hash: &password_hash,
|
|
||||||
now: &now,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "bootstrap_completed",
|
|
||||||
"user_id": user_id.as_str()
|
|
||||||
})
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recover_admin_password(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
options: AdminAuthOptions,
|
|
||||||
) -> Result<(), CliError> {
|
|
||||||
let email = options
|
|
||||||
.email
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
|
|
||||||
let password_path = options.password_file.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
|
|
||||||
})?;
|
|
||||||
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"provide_password_pepper_file",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let master_key_path = options.master_key_file.as_deref().ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"provide_master_key_file",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let master_key = read_secret_file(master_key_path, "master_key.input")?;
|
|
||||||
let active = registry
|
|
||||||
.active_master_key_identity()
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_identity_missing",
|
|
||||||
"master_key.identity",
|
|
||||||
"start_service_once_with_current_master_key",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"master_key_invalid",
|
|
||||||
"master_key.input",
|
|
||||||
"provide_current_master_key_file",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT
|
|
||||||
|| active.fingerprint != crypto.master_key_fingerprint()
|
|
||||||
{
|
|
||||||
return Err(CliError::new(
|
|
||||||
"master_key_identity_mismatch",
|
|
||||||
"master_key.identity",
|
|
||||||
"use_matching_master_key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = read_secret_file(password_path, "admin_auth.password")?;
|
|
||||||
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
|
|
||||||
if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"provide_bounded_secret_files",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let password_hash = hash_password(&password, &pepper).map_err(|_| {
|
|
||||||
CliError::new(
|
|
||||||
"admin_recovery_rejected",
|
|
||||||
"admin_auth.recovery",
|
|
||||||
"verify_local_inputs",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple());
|
|
||||||
let user_id = registry
|
|
||||||
.recover_admin_password(RecoverAdminPasswordRequest {
|
|
||||||
email,
|
|
||||||
password_hash: &password_hash,
|
|
||||||
audit_id: &audit_id,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "admin_recovered",
|
|
||||||
"user_id": user_id.as_str(),
|
|
||||||
"sessions_revoked": true,
|
|
||||||
"audit_id": audit_id
|
|
||||||
})
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_bootstrap_contract(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
options: AdminAuthOptions,
|
|
||||||
) -> Result<(), CliError> {
|
|
||||||
let email = options
|
|
||||||
.email
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
|
|
||||||
let display_name = options.display_name.as_deref().unwrap_or("Crank Owner");
|
|
||||||
let ttl_seconds = options.ttl_seconds.unwrap_or(900);
|
|
||||||
if !(60..=86_400).contains(&ttl_seconds) {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"set_ttl_between_60_and_86400",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let token = random_token();
|
|
||||||
let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple());
|
|
||||||
let token_hash = admin_auth_hash("bootstrap", &token);
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let expires_at = now
|
|
||||||
.checked_add(TimeDuration::seconds(ttl_seconds))
|
|
||||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?;
|
|
||||||
let contract = registry
|
|
||||||
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
|
|
||||||
id: &contract_id,
|
|
||||||
token_hash: &token_hash,
|
|
||||||
email,
|
|
||||||
display_name,
|
|
||||||
expires_at: &expires_at,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(CliError::from_registry)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"status": "bootstrap_created",
|
|
||||||
"contract_id": contract.id,
|
|
||||||
"expires_at": contract.expires_at,
|
|
||||||
"bootstrap_token": token,
|
|
||||||
"warning": "copy_once_token_not_logged_by_services"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct AdminAuthOptions {
|
|
||||||
email: Option<String>,
|
|
||||||
display_name: Option<String>,
|
|
||||||
ttl_seconds: Option<i64>,
|
|
||||||
token_file: Option<PathBuf>,
|
|
||||||
password_file: Option<PathBuf>,
|
|
||||||
password_pepper_file: Option<PathBuf>,
|
|
||||||
master_key_file: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminAuthOptions {
|
|
||||||
fn parse(arguments: &[String]) -> Result<Self, CliError> {
|
|
||||||
let mut options = Self::default();
|
|
||||||
let mut index = 0;
|
|
||||||
while index < arguments.len() {
|
|
||||||
let key = arguments[index].as_str();
|
|
||||||
let Some(value) = arguments.get(index + 1) else {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_admin_auth_bootstrap_create",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match key {
|
|
||||||
"--email" => options.email = Some(value.clone()),
|
|
||||||
"--display-name" => options.display_name = Some(value.clone()),
|
|
||||||
"--ttl-seconds" => {
|
|
||||||
options.ttl_seconds = Some(value.parse::<i64>().map_err(|_| {
|
|
||||||
CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds")
|
|
||||||
})?);
|
|
||||||
}
|
|
||||||
"--password-file" => options.password_file = Some(PathBuf::from(value)),
|
|
||||||
"--token-file" => options.token_file = Some(PathBuf::from(value)),
|
|
||||||
"--password-pepper-file" => {
|
|
||||||
options.password_pepper_file = Some(PathBuf::from(value));
|
|
||||||
}
|
|
||||||
"--master-key-file" => options.master_key_file = Some(PathBuf::from(value)),
|
|
||||||
_ => {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
"cli.arguments",
|
|
||||||
"run_admin_auth_bootstrap_create",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
}
|
|
||||||
Ok(options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_secret_file(path: &Path, stage: &'static str) -> Result<String, CliError> {
|
|
||||||
let metadata = std::fs::metadata(path)
|
|
||||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
|
|
||||||
if !metadata.is_file() || metadata.len() > 8_192 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
stage,
|
|
||||||
"provide_bounded_secret_file",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let bytes = std::fs::read(path)
|
|
||||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
|
|
||||||
if bytes.len() > 8_192 {
|
|
||||||
return Err(CliError::new(
|
|
||||||
"invalid_command",
|
|
||||||
stage,
|
|
||||||
"provide_bounded_secret_file",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut value = String::from_utf8(bytes)
|
|
||||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?;
|
|
||||||
if value.ends_with("\r\n") {
|
|
||||||
value.truncate(value.len() - 2);
|
|
||||||
} else if value.ends_with('\n') {
|
|
||||||
value.truncate(value.len() - 1);
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_token() -> String {
|
|
||||||
let mut bytes = [0_u8; 32];
|
|
||||||
rand::rng().fill(&mut bytes);
|
|
||||||
URL_SAFE_NO_PAD.encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_auth_hash(scope: &str, token: &str) -> String {
|
|
||||||
let mut digest = Sha256::new();
|
|
||||||
digest.update(scope.as_bytes());
|
|
||||||
digest.update(b":");
|
|
||||||
digest.update(token.as_bytes());
|
|
||||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
use crank_config::DatabaseSettings;
|
|
||||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError};
|
|
||||||
use sqlx::{
|
|
||||||
PgPool,
|
|
||||||
postgres::{PgConnectOptions, PgPoolOptions},
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::CliError;
|
|
||||||
|
|
||||||
pub(super) async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
|
|
||||||
let options = connect_options(config)?;
|
|
||||||
for attempt in 1..=10 {
|
|
||||||
let result = PgPoolOptions::new()
|
|
||||||
.max_connections(config.pool.max_connections)
|
|
||||||
.min_connections(config.pool.min_connections)
|
|
||||||
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
|
|
||||||
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
|
|
||||||
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
|
|
||||||
.connect_with(options.clone())
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(pool) => return Ok(pool),
|
|
||||||
Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await,
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(CliError::new(
|
|
||||||
"storage_unavailable",
|
|
||||||
"database.connect",
|
|
||||||
"contact_operator",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn connect_registry(
|
|
||||||
config: &DatabaseSettings,
|
|
||||||
) -> Result<PostgresRegistry, CliError> {
|
|
||||||
let options = connect_options(config)?;
|
|
||||||
let pool_config = PostgresPoolConfig {
|
|
||||||
max_connections: config.pool.max_connections,
|
|
||||||
min_connections: config.pool.min_connections,
|
|
||||||
acquire_timeout_ms: config.pool.acquire_timeout_ms,
|
|
||||||
idle_timeout_ms: config.pool.idle_timeout_ms,
|
|
||||||
max_lifetime_ms: config.pool.max_lifetime_ms,
|
|
||||||
};
|
|
||||||
for attempt in 1..=10 {
|
|
||||||
let result =
|
|
||||||
PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config)
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(registry) => return Ok(registry),
|
|
||||||
Err(RegistryError::Storage(_)) if attempt < 10 => {
|
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(CliError::from_registry(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(CliError::new(
|
|
||||||
"storage_unavailable",
|
|
||||||
"database.connect",
|
|
||||||
"contact_operator",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect_options(config: &DatabaseSettings) -> Result<PgConnectOptions, CliError> {
|
|
||||||
if let Some(url) = &config.url {
|
|
||||||
url.expose_secret()
|
|
||||||
.parse::<PgConnectOptions>()
|
|
||||||
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))
|
|
||||||
} else {
|
|
||||||
Ok(PgConnectOptions::new()
|
|
||||||
.host(&config.host)
|
|
||||||
.port(config.port)
|
|
||||||
.database(&config.database)
|
|
||||||
.username(&config.username)
|
|
||||||
.password(config.password.expose_secret()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,735 +0,0 @@
|
|||||||
use crank_core::{
|
|
||||||
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
|
||||||
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationAvailability,
|
|
||||||
OperationSecurityLevel, OperationStatus, OperationVersionState, PlatformApiKeyKind,
|
|
||||||
PlatformApiKeyScope, Protocol, SecretKind, Target, ToolSelectionPolicy, UsagePeriod,
|
|
||||||
WizardState, WorkspaceId, WorkspaceStatus,
|
|
||||||
};
|
|
||||||
use crank_mapping::MappingSet;
|
|
||||||
use crank_registry::{
|
|
||||||
InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown,
|
|
||||||
UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint,
|
|
||||||
WorkspaceMembershipRecord, WorkspaceRecord,
|
|
||||||
};
|
|
||||||
use crank_schema::Schema;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct LoginPayload {
|
|
||||||
pub email: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct CompleteBootstrapPayload {
|
|
||||||
pub token: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct BootstrapStatusResponse {
|
|
||||||
pub bootstrap_required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct SessionResponse {
|
|
||||||
pub user: crank_core::User,
|
|
||||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
|
||||||
pub current_workspace_id: Option<String>,
|
|
||||||
pub csrf_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpdateProfilePayload {
|
|
||||||
pub display_name: String,
|
|
||||||
pub email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ChangePasswordPayload {
|
|
||||||
pub current_password: String,
|
|
||||||
pub new_password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpdateCurrentWorkspacePayload {
|
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct OperationPayload {
|
|
||||||
pub name: String,
|
|
||||||
pub display_name: String,
|
|
||||||
#[serde(default = "default_operation_category")]
|
|
||||||
pub category: String,
|
|
||||||
pub protocol: Protocol,
|
|
||||||
#[serde(default)]
|
|
||||||
pub security_level: OperationSecurityLevel,
|
|
||||||
pub target: Target,
|
|
||||||
pub input_schema: Schema,
|
|
||||||
pub output_schema: Schema,
|
|
||||||
pub input_mapping: MappingSet,
|
|
||||||
pub output_mapping: MappingSet,
|
|
||||||
pub execution_config: crank_core::ExecutionConfig,
|
|
||||||
pub tool_description: crank_core::ToolDescription,
|
|
||||||
#[serde(default)]
|
|
||||||
pub wizard_state: Option<WizardState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct NewVersionPayload {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub operation: OperationPayload,
|
|
||||||
#[serde(default)]
|
|
||||||
pub change_note: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct PublishPayload {
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct TestRunPayload {
|
|
||||||
pub version: u32,
|
|
||||||
pub input: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub confirmation_token: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub locale: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct TestRunResult {
|
|
||||||
pub ok: bool,
|
|
||||||
pub mode: ExecutionMode,
|
|
||||||
pub tested_version: u32,
|
|
||||||
pub request_id: String,
|
|
||||||
pub trace_id: String,
|
|
||||||
pub request_preview: Value,
|
|
||||||
pub response_preview: Value,
|
|
||||||
pub errors: Vec<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct AuthProfilePayload {
|
|
||||||
pub name: String,
|
|
||||||
pub kind: AuthKind,
|
|
||||||
pub config: AuthConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpstreamPayload {
|
|
||||||
pub name: String,
|
|
||||||
pub base_url: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub static_headers: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auth_profile_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct SecretPayload {
|
|
||||||
pub name: String,
|
|
||||||
pub kind: SecretKind,
|
|
||||||
pub value: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct RotateSecretPayload {
|
|
||||||
pub value: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct WorkspacePayload {
|
|
||||||
pub slug: String,
|
|
||||||
pub display_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub settings: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpdateWorkspacePayload {
|
|
||||||
pub slug: Option<String>,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub status: Option<WorkspaceStatus>,
|
|
||||||
pub settings: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct AgentPayload {
|
|
||||||
pub slug: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub description: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub instructions: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tool_selection_policy: ToolSelectionPolicy,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpdateAgentPayload {
|
|
||||||
pub slug: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct AgentBindingPayload {
|
|
||||||
pub operation_id: String,
|
|
||||||
pub operation_version: u32,
|
|
||||||
pub tool_name: String,
|
|
||||||
pub tool_title: String,
|
|
||||||
pub tool_description_override: Option<String>,
|
|
||||||
#[serde(default = "default_enabled")]
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ToolSearchPreviewPayload {
|
|
||||||
pub query: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub group_ids: Vec<String>,
|
|
||||||
pub bindings: Vec<AgentBindingPayload>,
|
|
||||||
pub tool_selection_policy: ToolSelectionPolicy,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum AgentCatalogPayload {
|
|
||||||
Bindings(Vec<AgentBindingPayload>),
|
|
||||||
Config {
|
|
||||||
bindings: Vec<AgentBindingPayload>,
|
|
||||||
tool_selection_policy: ToolSelectionPolicy,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentCatalogPayload {
|
|
||||||
pub fn into_parts(self) -> (Vec<AgentBindingPayload>, Option<ToolSelectionPolicy>) {
|
|
||||||
match self {
|
|
||||||
Self::Bindings(bindings) => (bindings, None),
|
|
||||||
Self::Config {
|
|
||||||
bindings,
|
|
||||||
tool_selection_policy,
|
|
||||||
} => (bindings, Some(tool_selection_policy)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<AgentBindingPayload>> for AgentCatalogPayload {
|
|
||||||
fn from(bindings: Vec<AgentBindingPayload>) -> Self {
|
|
||||||
Self::Bindings(bindings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CreatedAgentResponse {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub version: u32,
|
|
||||||
pub status: AgentStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct PublishAgentResponse {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub published_version: u32,
|
|
||||||
pub published_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct AgentSummaryView {
|
|
||||||
pub id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub status: AgentStatus,
|
|
||||||
pub current_draft_version: u32,
|
|
||||||
pub latest_published_version: Option<u32>,
|
|
||||||
pub catalog_revision: i64,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
pub published_at: Option<String>,
|
|
||||||
pub operation_count: usize,
|
|
||||||
pub operation_ids: Vec<String>,
|
|
||||||
pub tool_selection_policy: ToolSelectionPolicy,
|
|
||||||
pub key_count: usize,
|
|
||||||
pub calls_today: u64,
|
|
||||||
pub mcp_endpoint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct AgentMutationResult {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct PlatformApiKeyPayload {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default = "default_platform_api_key_kind")]
|
|
||||||
pub key_kind: PlatformApiKeyKind,
|
|
||||||
pub scopes: Vec<PlatformApiKeyScope>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub expires_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub allowed_origins: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
|
|
||||||
PlatformApiKeyKind::McpClient
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CreatedPlatformApiKeyResponse {
|
|
||||||
pub api_key: PlatformApiKeyRecord,
|
|
||||||
pub secret: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub connection: Option<EphemeralMcpConnection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct EphemeralMcpClientConfig {
|
|
||||||
pub client: String,
|
|
||||||
pub config: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct EphemeralMcpConnection {
|
|
||||||
pub endpoint: String,
|
|
||||||
pub clients: Vec<EphemeralMcpClientConfig>,
|
|
||||||
pub secret_display: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct OnboardingEventPayload {
|
|
||||||
pub event: String,
|
|
||||||
pub idempotency_key: String,
|
|
||||||
pub expected_revision: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub completed_steps: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OnboardingStepView {
|
|
||||||
pub id: crank_core::OnboardingStepId,
|
|
||||||
pub completed: bool,
|
|
||||||
pub status: String,
|
|
||||||
pub action_code: String,
|
|
||||||
pub reason_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OnboardingFirstCallEvidence {
|
|
||||||
pub log_id: String,
|
|
||||||
pub agent_id: String,
|
|
||||||
pub key_id: String,
|
|
||||||
pub operation_id: String,
|
|
||||||
pub operation_version: u32,
|
|
||||||
pub tool_name: String,
|
|
||||||
pub occurred_at: String,
|
|
||||||
pub request_id: Option<String>,
|
|
||||||
pub trace_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OnboardingResponse {
|
|
||||||
pub schema_version: u16,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub revision: i64,
|
|
||||||
pub status: String,
|
|
||||||
pub completed: bool,
|
|
||||||
pub eligible_since: Option<String>,
|
|
||||||
pub steps: Vec<OnboardingStepView>,
|
|
||||||
pub operation_id: Option<String>,
|
|
||||||
pub operation_version: Option<u32>,
|
|
||||||
pub agent_id: Option<String>,
|
|
||||||
pub catalog_revision: Option<i64>,
|
|
||||||
pub platform_api_key_id: Option<String>,
|
|
||||||
pub mcp_endpoint: Option<String>,
|
|
||||||
pub first_call: Option<OnboardingFirstCallEvidence>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OnboardingEventResponse {
|
|
||||||
pub accepted: bool,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub onboarding: OnboardingResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ResetOnboardingSelectionPayload {
|
|
||||||
pub expected_revision: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct ResetOnboardingSelectionResponse {
|
|
||||||
pub selection_reset: bool,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub onboarding: OnboardingResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct WorkspaceCatalogSnapshotResponse {
|
|
||||||
pub kind: String,
|
|
||||||
pub format_version: String,
|
|
||||||
pub restorable: bool,
|
|
||||||
pub included: Vec<String>,
|
|
||||||
pub excluded: Vec<String>,
|
|
||||||
pub workspace: WorkspaceRecord,
|
|
||||||
pub operations: Vec<OperationSummaryView>,
|
|
||||||
pub agents: Vec<AgentSummaryView>,
|
|
||||||
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
|
|
||||||
pub exported_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct LogsQuery {
|
|
||||||
pub level: Option<InvocationLevel>,
|
|
||||||
pub status: Option<InvocationStatus>,
|
|
||||||
pub outcome_group: Option<UsageOutcomeGroup>,
|
|
||||||
pub search: Option<String>,
|
|
||||||
pub source: Option<InvocationSource>,
|
|
||||||
pub operation_id: Option<String>,
|
|
||||||
pub agent_id: Option<String>,
|
|
||||||
pub period: Option<UsagePeriod>,
|
|
||||||
pub created_after: Option<String>,
|
|
||||||
pub created_before: Option<String>,
|
|
||||||
pub cursor: Option<String>,
|
|
||||||
pub limit: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ApprovalsQuery {
|
|
||||||
pub status: Option<ApprovalRequestStatus>,
|
|
||||||
pub limit: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ApprovalDecisionPayload {
|
|
||||||
pub approve: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub note: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UsageRequestQuery {
|
|
||||||
pub period: Option<UsagePeriod>,
|
|
||||||
pub source: Option<InvocationSource>,
|
|
||||||
pub created_after: Option<String>,
|
|
||||||
pub created_before: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct UsageOverviewResponse {
|
|
||||||
pub summary: UsageSummary,
|
|
||||||
pub timeline: Vec<UsageTimelinePoint>,
|
|
||||||
pub operations: Vec<UsageOperationBreakdown>,
|
|
||||||
pub agents: Vec<UsageAgentBreakdown>,
|
|
||||||
pub outcomes: Vec<crank_registry::UsageOutcomeBreakdown>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct LogsListResponse {
|
|
||||||
pub items: Vec<InvocationLogRecord>,
|
|
||||||
pub next_cursor: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct ProtocolCapabilityView {
|
|
||||||
pub protocol: Protocol,
|
|
||||||
pub supports_execution_modes: Vec<ExecutionMode>,
|
|
||||||
pub supports_transport_behaviors: Vec<String>,
|
|
||||||
pub supports_auth_kinds: Vec<String>,
|
|
||||||
pub supports_upload_artifacts: Vec<String>,
|
|
||||||
pub supports_cursor_path: bool,
|
|
||||||
pub supports_done_path: bool,
|
|
||||||
pub supports_aggregation_mode: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct GenerateDraftPayload {
|
|
||||||
#[serde(default)]
|
|
||||||
pub sources: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct DraftGenerationResult {
|
|
||||||
pub generated_draft: GeneratedDraft,
|
|
||||||
pub input_schema: Schema,
|
|
||||||
pub output_schema: Schema,
|
|
||||||
pub input_mapping: MappingSet,
|
|
||||||
pub output_mapping: MappingSet,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ImportQuery {
|
|
||||||
#[serde(default)]
|
|
||||||
pub mode: ImportMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ImportMode {
|
|
||||||
#[default]
|
|
||||||
Create,
|
|
||||||
Upsert,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct ExportQuery {
|
|
||||||
pub version: Option<u32>,
|
|
||||||
#[serde(default = "default_export_mode")]
|
|
||||||
pub mode: ExportMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct YamlOperationDocument {
|
|
||||||
pub format_version: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub operation: PortableOperation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct PortableOperation {
|
|
||||||
pub name: String,
|
|
||||||
pub display_name: String,
|
|
||||||
#[serde(default = "default_operation_category")]
|
|
||||||
pub category: String,
|
|
||||||
pub protocol: Protocol,
|
|
||||||
#[serde(default)]
|
|
||||||
pub security_level: OperationSecurityLevel,
|
|
||||||
pub target: Target,
|
|
||||||
pub input_schema: Schema,
|
|
||||||
pub output_schema: Schema,
|
|
||||||
pub input_mapping: MappingSet,
|
|
||||||
pub output_mapping: MappingSet,
|
|
||||||
pub execution_config: crank_core::ExecutionConfig,
|
|
||||||
pub tool_description: crank_core::ToolDescription,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct LegacyYamlOperationDocument {
|
|
||||||
pub format_version: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub operation: RegistryOperation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum OpenApiUploadLocale {
|
|
||||||
En,
|
|
||||||
Ru,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct OpenApiUpload {
|
|
||||||
pub bytes: Vec<u8>,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub locale: OpenApiUploadLocale,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OpenApiImportPreviewResponse {
|
|
||||||
pub job_id: String,
|
|
||||||
pub expires_at: String,
|
|
||||||
pub preview: crank_import::rest::ImportPreview,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct OpenApiImportCreatePayload {
|
|
||||||
pub selected_operation_keys: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub server_url: Option<String>,
|
|
||||||
#[serde(default = "default_openapi_conflict_mode")]
|
|
||||||
pub conflict_mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OpenApiImportCreateResponse {
|
|
||||||
pub created: Vec<OpenApiImportCreatedOperation>,
|
|
||||||
pub skipped: Vec<OpenApiImportSkippedOperation>,
|
|
||||||
pub findings: Vec<crank_import::rest::ImportFinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct OpenApiImportCreatedOperation {
|
|
||||||
pub operation_key: String,
|
|
||||||
pub operation_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct OpenApiImportSkippedOperation {
|
|
||||||
pub operation_key: String,
|
|
||||||
pub name: String,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CreatedOperationResponse {
|
|
||||||
pub operation_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub version: u32,
|
|
||||||
pub status: OperationStatus,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct PublishResponse {
|
|
||||||
pub operation_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub published_version: u32,
|
|
||||||
pub published_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct UpdateOperationPayload {
|
|
||||||
pub display_name: String,
|
|
||||||
#[serde(default = "default_operation_category")]
|
|
||||||
pub category: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub security_level: OperationSecurityLevel,
|
|
||||||
pub target: Target,
|
|
||||||
pub input_schema: Schema,
|
|
||||||
pub output_schema: Schema,
|
|
||||||
pub input_mapping: MappingSet,
|
|
||||||
pub output_mapping: MappingSet,
|
|
||||||
pub execution_config: crank_core::ExecutionConfig,
|
|
||||||
pub tool_description: crank_core::ToolDescription,
|
|
||||||
#[serde(default)]
|
|
||||||
pub wizard_state: Option<WizardState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperationUsageSummaryView {
|
|
||||||
pub calls_today: u64,
|
|
||||||
pub error_rate_pct: f64,
|
|
||||||
pub avg_latency_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperationAgentRefView {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub agent_slug: String,
|
|
||||||
pub display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperationSummaryView {
|
|
||||||
pub id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub category: String,
|
|
||||||
pub protocol: Protocol,
|
|
||||||
pub security_level: OperationSecurityLevel,
|
|
||||||
pub target_url: String,
|
|
||||||
pub target_action: String,
|
|
||||||
pub status: OperationStatus,
|
|
||||||
pub current_draft_version: u32,
|
|
||||||
pub latest_published_version: Option<u32>,
|
|
||||||
pub can_delete: bool,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
pub published_at: Option<String>,
|
|
||||||
pub usage_summary: OperationUsageSummaryView,
|
|
||||||
pub agent_refs: Vec<OperationAgentRefView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperationDetailView {
|
|
||||||
pub id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub category: String,
|
|
||||||
pub protocol: Protocol,
|
|
||||||
pub security_level: OperationSecurityLevel,
|
|
||||||
pub status: OperationStatus,
|
|
||||||
pub availability: OperationAvailability,
|
|
||||||
pub current_draft_version: u32,
|
|
||||||
pub latest_published_version: Option<u32>,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
pub published_at: Option<String>,
|
|
||||||
pub draft_version_ref: VersionRef,
|
|
||||||
pub published_version_ref: Option<VersionRef>,
|
|
||||||
pub agent_refs: Vec<OperationAgentRefView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct VersionRef {
|
|
||||||
pub version: u32,
|
|
||||||
pub status: OperationVersionState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperationMutationResult {
|
|
||||||
pub operation_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub version: u32,
|
|
||||||
pub status: OperationStatus,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct ImportResponse {
|
|
||||||
pub operation_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub version: u32,
|
|
||||||
pub import_mode: ImportMode,
|
|
||||||
pub warnings: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct DescriptorUploadResponse {
|
|
||||||
pub descriptor_id: String,
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_operation_category() -> String {
|
|
||||||
"general".to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_openapi_conflict_mode() -> String {
|
|
||||||
"rename".to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_export_mode() -> ExportMode {
|
|
||||||
ExportMode::Portable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_enabled() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct InvocationRecordRequest<'a> {
|
|
||||||
pub workspace_id: &'a WorkspaceId,
|
|
||||||
pub agent_id: Option<&'a AgentId>,
|
|
||||||
pub operation: &'a RegistryOperation,
|
|
||||||
pub request_id: Option<&'a str>,
|
|
||||||
pub trace_id: Option<&'a str>,
|
|
||||||
pub source: InvocationSource,
|
|
||||||
pub level: InvocationLevel,
|
|
||||||
pub status: InvocationStatus,
|
|
||||||
pub message: String,
|
|
||||||
pub status_code: Option<u16>,
|
|
||||||
pub error_kind: Option<String>,
|
|
||||||
pub execution_stage: Option<crank_core::ExecutionStage>,
|
|
||||||
pub execution_error_code: Option<crank_core::ExecutionErrorCode>,
|
|
||||||
pub retryability: Option<crank_core::Retryability>,
|
|
||||||
pub outcome_certainty: Option<crank_core::OutcomeCertainty>,
|
|
||||||
pub duration_ms: u64,
|
|
||||||
pub request_preview: Value,
|
|
||||||
pub response_preview: Value,
|
|
||||||
}
|
|
||||||
+204
-431
@@ -11,7 +11,6 @@ use serde_json::{Value, json};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::dto::OpenApiUploadLocale;
|
|
||||||
use crate::storage::StorageError;
|
use crate::storage::StorageError;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -32,11 +31,6 @@ pub enum ApiError {
|
|||||||
context: Option<Value>,
|
context: Option<Value>,
|
||||||
},
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
Unprocessable {
|
|
||||||
message: String,
|
|
||||||
context: Option<Value>,
|
|
||||||
},
|
|
||||||
#[error("{message}")]
|
|
||||||
NotFound {
|
NotFound {
|
||||||
message: String,
|
message: String,
|
||||||
context: Option<Value>,
|
context: Option<Value>,
|
||||||
@@ -47,16 +41,6 @@ pub enum ApiError {
|
|||||||
context: Option<Value>,
|
context: Option<Value>,
|
||||||
},
|
},
|
||||||
#[error("{message}")]
|
#[error("{message}")]
|
||||||
PayloadTooLarge {
|
|
||||||
message: String,
|
|
||||||
context: Option<Value>,
|
|
||||||
},
|
|
||||||
#[error("{message}")]
|
|
||||||
PreconditionRequired {
|
|
||||||
message: String,
|
|
||||||
context: Option<Value>,
|
|
||||||
},
|
|
||||||
#[error("{message}")]
|
|
||||||
RateLimited {
|
RateLimited {
|
||||||
message: String,
|
message: String,
|
||||||
context: Option<Value>,
|
context: Option<Value>,
|
||||||
@@ -90,111 +74,13 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal(_message: impl Into<String>) -> Self {
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
Self::Internal {
|
Self::Internal {
|
||||||
message: "internal server error".to_owned(),
|
message: message.into(),
|
||||||
context: None,
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn openapi_upload(locale: OpenApiUploadLocale, code: &'static str) -> Self {
|
|
||||||
let russian = locale == OpenApiUploadLocale::Ru;
|
|
||||||
let message = match (russian, code) {
|
|
||||||
(_, "file_too_large") => {
|
|
||||||
if russian {
|
|
||||||
"файл OpenAPI превышает лимит 256 КиБ"
|
|
||||||
} else {
|
|
||||||
"OpenAPI file exceeds the 256 KiB limit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "empty_file") => {
|
|
||||||
if russian {
|
|
||||||
"файл OpenAPI не должен быть пустым"
|
|
||||||
} else {
|
|
||||||
"OpenAPI file must not be empty"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "invalid_utf8") => {
|
|
||||||
if russian {
|
|
||||||
"файл OpenAPI должен быть в UTF-8"
|
|
||||||
} else {
|
|
||||||
"OpenAPI file must be UTF-8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "invalid_media_type") => {
|
|
||||||
if russian {
|
|
||||||
"тип файла OpenAPI не поддерживается"
|
|
||||||
} else {
|
|
||||||
"OpenAPI file type is not supported"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "invalid_document") => {
|
|
||||||
if russian {
|
|
||||||
"некорректный или неподдерживаемый документ OpenAPI"
|
|
||||||
} else {
|
|
||||||
"OpenAPI document is invalid or unsupported"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "no_methods") => {
|
|
||||||
if russian {
|
|
||||||
"документ OpenAPI не содержит поддерживаемых методов"
|
|
||||||
} else {
|
|
||||||
"OpenAPI document contains no supported methods"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "source_integrity") => {
|
|
||||||
if russian {
|
|
||||||
"проверка целостности источника OpenAPI не пройдена"
|
|
||||||
} else {
|
|
||||||
"OpenAPI source integrity verification failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "source_unavailable") => {
|
|
||||||
if russian {
|
|
||||||
"источник OpenAPI недоступен"
|
|
||||||
} else {
|
|
||||||
"OpenAPI source is unavailable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, "parser_unavailable") | (_, "storage_unavailable") => {
|
|
||||||
if russian {
|
|
||||||
"обработка OpenAPI временно недоступна"
|
|
||||||
} else {
|
|
||||||
"OpenAPI processing is temporarily unavailable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if russian {
|
|
||||||
"некорректная multipart-загрузка OpenAPI"
|
|
||||||
} else {
|
|
||||||
"invalid OpenAPI multipart upload"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let context = json!({ "error_code": format!("openapi_upload.{code}") });
|
|
||||||
if code == "file_too_large" {
|
|
||||||
Self::PayloadTooLarge {
|
|
||||||
message: message.to_owned(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
} else if matches!(code, "storage_unavailable" | "parser_unavailable") {
|
|
||||||
Self::Internal {
|
|
||||||
message: message.to_owned(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
} else if matches!(code, "source_integrity" | "source_unavailable") {
|
|
||||||
Self::Unprocessable {
|
|
||||||
message: message.to_owned(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Self::Validation {
|
|
||||||
message: message.to_owned(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
|
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
Self::RateLimited {
|
Self::RateLimited {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
@@ -209,13 +95,6 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
||||||
Self::Unprocessable {
|
|
||||||
message: message.into(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
Self::NotFound {
|
Self::NotFound {
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
@@ -230,21 +109,8 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn payload_too_large_with_context(
|
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||||
message: impl Into<String>,
|
Self::Forbidden {
|
||||||
context: Value,
|
|
||||||
) -> Self {
|
|
||||||
Self::PayloadTooLarge {
|
|
||||||
message: message.into(),
|
|
||||||
context: Some(context),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn precondition_required_with_context(
|
|
||||||
message: impl Into<String>,
|
|
||||||
context: Value,
|
|
||||||
) -> Self {
|
|
||||||
Self::PreconditionRequired {
|
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
context: Some(context),
|
context: Some(context),
|
||||||
}
|
}
|
||||||
@@ -255,33 +121,20 @@ impl ApiError {
|
|||||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||||
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
|
||||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||||
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
|
|
||||||
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
|
|
||||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn code(&self) -> &str {
|
fn code(&self) -> &'static str {
|
||||||
if let Some(code) = self
|
|
||||||
.context_ref()
|
|
||||||
.and_then(|context| context.get("error_code"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
match self {
|
match self {
|
||||||
Self::Unauthorized { .. } => "unauthorized",
|
Self::Unauthorized { .. } => "unauthorized",
|
||||||
Self::Forbidden { .. } => "forbidden",
|
Self::Forbidden { .. } => "forbidden",
|
||||||
Self::Validation { .. } => "validation_error",
|
Self::Validation { .. } => "validation_error",
|
||||||
Self::Unprocessable { .. } => "unprocessable_entity",
|
|
||||||
Self::NotFound { .. } => "not_found",
|
Self::NotFound { .. } => "not_found",
|
||||||
Self::Conflict { .. } => "conflict",
|
Self::Conflict { .. } => "conflict",
|
||||||
Self::PayloadTooLarge { .. } => "payload_too_large",
|
|
||||||
Self::PreconditionRequired { .. } => "precondition_required",
|
|
||||||
Self::RateLimited { .. } => "rate_limited",
|
Self::RateLimited { .. } => "rate_limited",
|
||||||
Self::Internal { .. } => "internal_error",
|
Self::Internal { .. } => "internal_error",
|
||||||
}
|
}
|
||||||
@@ -291,27 +144,16 @@ impl ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match &self {
|
match &self {
|
||||||
Self::Internal { .. } => {
|
Self::Internal { message, .. } => {
|
||||||
error!(
|
error!(error_code = self.code(), error_message = %message)
|
||||||
name: "admin.response.internal_error",
|
|
||||||
error_code = self.code(),
|
|
||||||
"internal API error response"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Self::Unauthorized { .. }
|
Self::Unauthorized { message, .. }
|
||||||
| Self::Forbidden { .. }
|
| Self::Forbidden { message, .. }
|
||||||
| Self::Validation { .. }
|
| Self::Validation { message, .. }
|
||||||
| Self::Unprocessable { .. }
|
| Self::NotFound { message, .. }
|
||||||
| Self::NotFound { .. }
|
| Self::Conflict { message, .. }
|
||||||
| Self::Conflict { .. }
|
| Self::RateLimited { message, .. } => {
|
||||||
| Self::PayloadTooLarge { .. }
|
warn!(error_code = self.code(), error_message = %message)
|
||||||
| Self::PreconditionRequired { .. }
|
|
||||||
| Self::RateLimited { .. } => {
|
|
||||||
warn!(
|
|
||||||
name: "admin.response.rejected",
|
|
||||||
error_code = self.code(),
|
|
||||||
"API request rejected"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +164,6 @@ impl IntoResponse for ApiError {
|
|||||||
if let Some(context) = self.context() {
|
if let Some(context) = self.context() {
|
||||||
error["context"] = context;
|
error["context"] = context;
|
||||||
}
|
}
|
||||||
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
|
||||||
if let Some(request_id) = request_id {
|
|
||||||
error["request_id"] = Value::String(request_id);
|
|
||||||
}
|
|
||||||
if let Some(trace_id) = trace_id {
|
|
||||||
error["trace_id"] = Value::String(trace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = Json(json!({
|
let body = Json(json!({
|
||||||
"error": error
|
"error": error
|
||||||
@@ -339,24 +174,17 @@ impl IntoResponse for ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ApiError {
|
impl ApiError {
|
||||||
fn context_ref(&self) -> Option<&Value> {
|
fn context(&self) -> Option<Value> {
|
||||||
match self {
|
match self {
|
||||||
Self::Unauthorized { context, .. }
|
Self::Unauthorized { context, .. }
|
||||||
| Self::Forbidden { context, .. }
|
| Self::Forbidden { context, .. }
|
||||||
| Self::Validation { context, .. }
|
| Self::Validation { context, .. }
|
||||||
| Self::Unprocessable { context, .. }
|
|
||||||
| Self::NotFound { context, .. }
|
| Self::NotFound { context, .. }
|
||||||
| Self::Conflict { context, .. }
|
| Self::Conflict { context, .. }
|
||||||
| Self::PayloadTooLarge { context, .. }
|
|
||||||
| Self::PreconditionRequired { context, .. }
|
|
||||||
| Self::RateLimited { context, .. }
|
| Self::RateLimited { context, .. }
|
||||||
| Self::Internal { context, .. } => context.as_ref(),
|
| Self::Internal { context, .. } => context.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn context(&self) -> Option<Value> {
|
|
||||||
self.context_ref().cloned()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RegistryError> for ApiError {
|
impl From<RegistryError> for ApiError {
|
||||||
@@ -392,40 +220,17 @@ impl From<RegistryError> for ApiError {
|
|||||||
format!("platform api key {key_id} was not found"),
|
format!("platform api key {key_id} was not found"),
|
||||||
json!({ "key_id": key_id }),
|
json!({ "key_id": key_id }),
|
||||||
),
|
),
|
||||||
RegistryError::PlatformApiKeyInactive { key_id } => Self::conflict_with_context(
|
|
||||||
"platform api key is not active",
|
|
||||||
json!({
|
|
||||||
"key_id": key_id,
|
|
||||||
"error_code": "platform_api_key_not_active",
|
|
||||||
"recovery": "create_replacement_key"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
||||||
format!("secret {secret_id} was not found"),
|
format!("secret {secret_id} was not found"),
|
||||||
json!({ "secret_id": secret_id }),
|
json!({ "secret_id": secret_id }),
|
||||||
),
|
),
|
||||||
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
|
RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context(
|
||||||
"secret is not active",
|
format!("stream session {session_id} was not found"),
|
||||||
json!({
|
json!({ "session_id": session_id }),
|
||||||
"secret_id": secret_id,
|
|
||||||
"error_code": "secret_not_active",
|
|
||||||
"recovery": "rotate_or_replace_secret"
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
|
RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context(
|
||||||
"secret was updated concurrently",
|
format!("async job {job_id} was not found"),
|
||||||
json!({
|
json!({ "job_id": job_id }),
|
||||||
"secret_id": secret_id,
|
|
||||||
"error_code": "secret_concurrent_update",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context(
|
|
||||||
"master key rotation is in progress",
|
|
||||||
json!({
|
|
||||||
"error_code": "master_key_rotation_in_progress",
|
|
||||||
"recovery": "retry_after_rotation"
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
||||||
format!("invocation log {log_id} was not found"),
|
format!("invocation log {log_id} was not found"),
|
||||||
@@ -463,57 +268,6 @@ impl From<RegistryError> for ApiError {
|
|||||||
json!({ "operation_id": operation_id }),
|
json!({ "operation_id": operation_id }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
RegistryError::OperationArchived { operation_id } => Self::conflict_with_context(
|
|
||||||
format!("operation {operation_id} is archived"),
|
|
||||||
json!({ "operation_id": operation_id, "error_code": "operation_archived" }),
|
|
||||||
),
|
|
||||||
RegistryError::OperationStaleVersion {
|
|
||||||
operation_id,
|
|
||||||
expected,
|
|
||||||
actual,
|
|
||||||
} => Self::conflict_with_context(
|
|
||||||
format!("operation {operation_id} has a stale base version"),
|
|
||||||
json!({
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"current_version": expected,
|
|
||||||
"provided_version": actual,
|
|
||||||
"error_code": "operation_stale_version",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::InvalidOperationTransition {
|
|
||||||
operation_id,
|
|
||||||
from,
|
|
||||||
action,
|
|
||||||
} => Self::conflict_with_context(
|
|
||||||
format!("operation {operation_id} cannot perform {action} from {from}"),
|
|
||||||
json!({
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"state": from,
|
|
||||||
"action": action,
|
|
||||||
"error_code": "operation_invalid_transition"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::OperationDeleteForbidden { operation_id } => {
|
|
||||||
Self::conflict_with_context(
|
|
||||||
format!(
|
|
||||||
"operation {operation_id} cannot be deleted because durable history exists"
|
|
||||||
),
|
|
||||||
json!({
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"error_code": "operation_delete_forbidden"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RegistryError::OperationAuthProfileUnavailable { operation_id } => {
|
|
||||||
Self::unprocessable_with_context(
|
|
||||||
"operation auth profile reference is unavailable",
|
|
||||||
json!({
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"error_code": "operation_auth_profile_invalid"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
|
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
|
||||||
format!("auth profile {auth_profile_id} was not found"),
|
format!("auth profile {auth_profile_id} was not found"),
|
||||||
json!({ "auth_profile_id": auth_profile_id }),
|
json!({ "auth_profile_id": auth_profile_id }),
|
||||||
@@ -522,17 +276,6 @@ impl From<RegistryError> for ApiError {
|
|||||||
format!("operation {operation_id} already exists"),
|
format!("operation {operation_id} already exists"),
|
||||||
json!({ "operation_id": operation_id }),
|
json!({ "operation_id": operation_id }),
|
||||||
),
|
),
|
||||||
RegistryError::PlatformApiKeyNameAlreadyExists { workspace_id, name } => {
|
|
||||||
Self::conflict_with_context(
|
|
||||||
"platform api key name already exists",
|
|
||||||
json!({
|
|
||||||
"workspace_id": workspace_id,
|
|
||||||
"name": name,
|
|
||||||
"error_code": "platform_api_key_name_conflict",
|
|
||||||
"recovery": "choose_different_name"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
|
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
|
||||||
format!("workspace with slug {slug} already exists"),
|
format!("workspace with slug {slug} already exists"),
|
||||||
json!({ "slug": slug }),
|
json!({ "slug": slug }),
|
||||||
@@ -543,8 +286,6 @@ impl From<RegistryError> for ApiError {
|
|||||||
json!({
|
json!({
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"error_code": "secret_name_conflict",
|
|
||||||
"recovery": "choose_different_name"
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -556,29 +297,34 @@ impl From<RegistryError> for ApiError {
|
|||||||
json!({
|
json!({
|
||||||
"secret_id": secret_id,
|
"secret_id": secret_id,
|
||||||
"auth_profile_id": auth_profile_id,
|
"auth_profile_id": auth_profile_id,
|
||||||
"error_code": "secret_referenced_by_auth_profile",
|
|
||||||
"recovery": "remove_or_update_auth_profile_reference"
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
RegistryError::InvalidStreamSessionTransition {
|
||||||
|
session_id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
} => Self::conflict_with_context(
|
||||||
|
format!("invalid stream session transition for {session_id}: {from} -> {to}"),
|
||||||
|
json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
RegistryError::InvalidAsyncJobTransition { job_id, from, to } => {
|
||||||
|
Self::conflict_with_context(
|
||||||
|
format!("invalid async job transition for {job_id}: {from} -> {to}"),
|
||||||
|
json!({
|
||||||
|
"job_id": job_id,
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||||
format!("user with email {email} already exists"),
|
format!("user with email {email} already exists"),
|
||||||
json!({ "email": email }),
|
json!({ "email": email }),
|
||||||
),
|
),
|
||||||
RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => {
|
|
||||||
Self::unauthorized("bootstrap request is invalid or expired")
|
|
||||||
}
|
|
||||||
RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"),
|
|
||||||
RegistryError::AdminLoginRateLimited { retry_after_ms } => {
|
|
||||||
Self::rate_limited_with_context(
|
|
||||||
"login temporarily unavailable",
|
|
||||||
json!({
|
|
||||||
"retry_after_ms": retry_after_ms.clamp(1, 300_000),
|
|
||||||
"error_code": "login_throttled",
|
|
||||||
"recovery": "retry_after_delay"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"),
|
|
||||||
RegistryError::InvalidInitialVersion {
|
RegistryError::InvalidInitialVersion {
|
||||||
operation_id,
|
operation_id,
|
||||||
version,
|
version,
|
||||||
@@ -601,51 +347,6 @@ impl From<RegistryError> for ApiError {
|
|||||||
"actual": actual,
|
"actual": actual,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
RegistryError::InvalidAgentVersionSequence {
|
|
||||||
agent_id,
|
|
||||||
expected,
|
|
||||||
actual,
|
|
||||||
} => Self::validation_with_context(
|
|
||||||
format!("agent {agent_id} expected next version {expected}, got {actual}"),
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"expected": expected,
|
|
||||||
"actual": actual,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::ImmutableAgentVersion { agent_id, version } => {
|
|
||||||
Self::conflict_with_context(
|
|
||||||
"Agent Version is immutable; reload before editing",
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"version": version,
|
|
||||||
"error_code": "agent_stale_revision",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context(
|
|
||||||
"Agent state changed; reload before retrying",
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"error_code": "agent_stale_revision",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::InvalidAgentTransition {
|
|
||||||
agent_id,
|
|
||||||
from,
|
|
||||||
action,
|
|
||||||
} => Self::conflict_with_context(
|
|
||||||
format!("agent {agent_id} cannot transition from {from} using {action}"),
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"from": from,
|
|
||||||
"action": action,
|
|
||||||
"error_code": "agent_invalid_transition",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::ImmutableOperationFieldChanged {
|
RegistryError::ImmutableOperationFieldChanged {
|
||||||
operation_id,
|
operation_id,
|
||||||
field,
|
field,
|
||||||
@@ -656,14 +357,6 @@ impl From<RegistryError> for ApiError {
|
|||||||
"field": field,
|
"field": field,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
RegistryError::OnboardingStaleRevision => Self::conflict_with_context(
|
|
||||||
"onboarding state changed; reload before retrying",
|
|
||||||
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
|
|
||||||
),
|
|
||||||
RegistryError::OnboardingIncomplete => Self::unprocessable_with_context(
|
|
||||||
"onboarding domain steps are not complete",
|
|
||||||
json!({"error_code":"onboarding_incomplete"}),
|
|
||||||
),
|
|
||||||
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
|
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
|
||||||
format!("unsupported enum representation for field {field}"),
|
format!("unsupported enum representation for field {field}"),
|
||||||
json!({ "field": field }),
|
json!({ "field": field }),
|
||||||
@@ -679,60 +372,8 @@ impl From<RegistryError> for ApiError {
|
|||||||
format!("yaml import job {job_id} was not found"),
|
format!("yaml import job {job_id} was not found"),
|
||||||
json!({ "job_id": job_id }),
|
json!({ "job_id": job_id }),
|
||||||
),
|
),
|
||||||
RegistryError::ImportJobNotFound { job_id } => Self::not_found_with_context(
|
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||||
format!("import job {job_id} was not found"),
|
Self::internal(value.to_string())
|
||||||
json!({ "job_id": job_id }),
|
|
||||||
),
|
|
||||||
RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context(
|
|
||||||
format!("import job {job_id} was already applied with different parameters"),
|
|
||||||
json!({ "job_id": job_id }),
|
|
||||||
),
|
|
||||||
RegistryError::SourceNotFound { .. } => Self::not_found_with_context(
|
|
||||||
"artifact source was not found",
|
|
||||||
json!({
|
|
||||||
"error_code": "artifact_source_not_found"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::SourceConflict { .. } => Self::conflict_with_context(
|
|
||||||
"artifact source metadata or lifecycle conflicts with the request",
|
|
||||||
json!({
|
|
||||||
"error_code": "artifact_source_conflict",
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::SourceUnavailable => Self::unprocessable_with_context(
|
|
||||||
"artifact source is unavailable",
|
|
||||||
json!({ "error_code": "artifact_source_unavailable" }),
|
|
||||||
),
|
|
||||||
RegistryError::SourceIntegrity => Self::unprocessable_with_context(
|
|
||||||
"artifact source failed integrity verification",
|
|
||||||
json!({ "error_code": "artifact_source_integrity" }),
|
|
||||||
),
|
|
||||||
RegistryError::ArtifactClaimInProgress => Self::conflict_with_context(
|
|
||||||
"artifact reconciliation is in progress",
|
|
||||||
json!({
|
|
||||||
"error_code": "artifact_claim_in_progress",
|
|
||||||
"recovery": "retry"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::InvalidArtifactSource { field } => Self::validation_with_context(
|
|
||||||
"artifact source metadata is invalid",
|
|
||||||
json!({
|
|
||||||
"field": field,
|
|
||||||
"error_code": "artifact_source_invalid"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
RegistryError::Storage(_) => Self::internal("registry operation failed"),
|
|
||||||
RegistryError::Migration(_)
|
|
||||||
| RegistryError::Serialization(_)
|
|
||||||
| RegistryError::MasterKeyIdentityMismatch { .. }
|
|
||||||
| RegistryError::InvalidMasterKeyIdentity
|
|
||||||
| RegistryError::MasterKeyRotationNotFound { .. }
|
|
||||||
| RegistryError::MasterKeyRotationConflict
|
|
||||||
| RegistryError::MasterKeyRotationVerificationFailed
|
|
||||||
| RegistryError::InvalidCorrelationIdentity { .. }
|
|
||||||
| RegistryError::InvalidExecutionRecord { .. } => {
|
|
||||||
Self::internal("registry operation failed")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,34 +406,166 @@ impl From<StorageError> for ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||||
let failure =
|
|
||||||
crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate());
|
|
||||||
execution_test_failure(&failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value {
|
|
||||||
execution_test_failure_localized(failure, crank_core::ExecutionLocale::En)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execution_test_failure_localized(
|
|
||||||
failure: &crank_core::ExecutionFailure,
|
|
||||||
locale: crank_core::ExecutionLocale,
|
|
||||||
) -> Value {
|
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"code": failure.error_code().as_str(),
|
"code": runtime_test_failure_code(error),
|
||||||
"message": failure.error_code().message(locale),
|
"message": error.to_string()
|
||||||
"stage": failure.stage().as_str(),
|
|
||||||
"retryability": failure.retryability().as_str(),
|
|
||||||
"outcome_certainty": failure.outcome_certainty().as_str(),
|
|
||||||
});
|
});
|
||||||
if let Some(status) = failure.upstream_status() {
|
if let Some(context) = runtime_error_context(error) {
|
||||||
payload["context"] = json!({ "upstream_status": status });
|
payload["context"] = context;
|
||||||
}
|
|
||||||
if let Some(challenge) = failure.confirmation() {
|
|
||||||
payload["context"] = json!({
|
|
||||||
"confirmation_token": challenge.token(),
|
|
||||||
"expires_in_ms": challenge.expires_in_ms(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||||
|
match error {
|
||||||
|
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||||
|
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||||
|
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||||
|
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||||
|
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||||
|
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||||
|
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||||
|
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||||
|
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||||
|
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||||
|
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||||
|
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||||
|
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||||
|
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||||
|
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||||
|
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||||
|
"runtime_secret_error"
|
||||||
|
}
|
||||||
|
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||||
|
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||||
|
match error {
|
||||||
|
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
|
||||||
|
"field": field,
|
||||||
|
"reason": reason,
|
||||||
|
})),
|
||||||
|
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
|
||||||
|
"field": field,
|
||||||
|
"reason": reason,
|
||||||
|
})),
|
||||||
|
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||||
|
"secret_id": secret_id,
|
||||||
|
"reason": reason,
|
||||||
|
})),
|
||||||
|
RuntimeError::SecretCrypto { operation, details } => Some(json!({
|
||||||
|
"operation": operation,
|
||||||
|
"details": details,
|
||||||
|
})),
|
||||||
|
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
|
||||||
|
"auth_profile_id": auth_profile_id,
|
||||||
|
})),
|
||||||
|
RuntimeError::MissingSecret { secret_id } => Some(json!({
|
||||||
|
"secret_id": secret_id,
|
||||||
|
})),
|
||||||
|
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
|
||||||
|
"secret_id": secret_id,
|
||||||
|
"version": version,
|
||||||
|
})),
|
||||||
|
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
|
||||||
|
"operation_id": operation_id,
|
||||||
|
})),
|
||||||
|
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"mode": mode,
|
||||||
|
})),
|
||||||
|
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
|
||||||
|
"protocol": protocol,
|
||||||
|
})),
|
||||||
|
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||||
|
"kind": kind,
|
||||||
|
"limit": limit,
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||||
|
use crank_registry::RegistryError;
|
||||||
|
use crank_runtime::RuntimeError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_test_failure_includes_structured_context() {
|
||||||
|
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
|
||||||
|
field: "request.headers".to_owned(),
|
||||||
|
reason: "must be an object".to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(payload["code"], "runtime_request_error");
|
||||||
|
assert_eq!(
|
||||||
|
payload["context"],
|
||||||
|
json!({
|
||||||
|
"field": "request.headers",
|
||||||
|
"reason": "must be an object"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||||
|
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||||
|
operation: "decode secret envelope",
|
||||||
|
details: "bad base64".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
json!({
|
||||||
|
"operation": "decode secret envelope",
|
||||||
|
"details": "bad base64"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||||
|
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||||
|
kind: "window",
|
||||||
|
limit: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(payload["code"], "runtime_overloaded");
|
||||||
|
assert_eq!(
|
||||||
|
payload["context"],
|
||||||
|
json!({
|
||||||
|
"kind": "window",
|
||||||
|
"limit": 16
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_errors_preserve_structured_context_in_api_error() {
|
||||||
|
let error = ApiError::from(RegistryError::InvalidAsyncJobTransition {
|
||||||
|
job_id: "job_123".to_owned(),
|
||||||
|
from: "running".to_owned(),
|
||||||
|
to: "completed".to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ApiError::Conflict { context, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
Some(json!({
|
||||||
|
"job_id": "job_123",
|
||||||
|
"from": "running",
|
||||||
|
"to": "completed"
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
use crank_core::{HttpMethod, Target};
|
|
||||||
use crank_mapping::MappingSet;
|
|
||||||
use crank_registry::RegistryOperation;
|
|
||||||
use crank_schema::{Schema, SchemaKind};
|
|
||||||
|
|
||||||
pub fn import_guidance_warnings(operation: &RegistryOperation) -> Vec<String> {
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
|
|
||||||
if is_generic_tool_name(&operation.name) {
|
|
||||||
warnings.push(
|
|
||||||
"Имя инструмента слишком общее. Используйте конкретное действие и объект, например get_customer_by_email.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if operation
|
|
||||||
.tool_description
|
|
||||||
.description
|
|
||||||
.trim()
|
|
||||||
.chars()
|
|
||||||
.count()
|
|
||||||
< 40
|
|
||||||
{
|
|
||||||
warnings.push(
|
|
||||||
"Описание инструмента короткое. Добавьте, когда его вызывать, какие входные данные нужны и что означает успешный ответ.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if schema_has_multi_action_field(&operation.input_schema) {
|
|
||||||
warnings.push(
|
|
||||||
"Входная схема похожа на endpoint с несколькими действиями. Если параметр action/mode/type выбирает разные сценарии, лучше разделить это на несколько MCP-инструментов.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if output_mapping_returns_broad_response(&operation.output_mapping) {
|
|
||||||
warnings.push(
|
|
||||||
"Настройка результата может отдавать агенту слишком широкий ответ. Выберите только поля, которые нужны модели для следующего шага.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
match &operation.target {
|
|
||||||
Target::Rest(target) => {
|
|
||||||
if operation.category == "general" {
|
|
||||||
warnings.push(
|
|
||||||
"Операция импортирована в общей категории. Перед привязкой к агенту сгруппируйте похожие endpoint-ы по конкретной задаче.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.method != HttpMethod::Get && operation.execution_config.idempotency.is_none()
|
|
||||||
{
|
|
||||||
warnings.push(
|
|
||||||
"Операция меняет состояние и не задает ключ идемпотентности. Для POST, PUT и PATCH добавьте стабильный ключ, если повторный вызов может создать дубль.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.method == HttpMethod::Delete {
|
|
||||||
warnings.push(
|
|
||||||
"DELETE будет выполняться через двухшаговое подтверждение: первый вызов выдаст токен, второй выполнит запрос.".to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_generic_tool_name(name: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
name.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"call_api" | "execute" | "execute_request" | "manage" | "manage_resource" | "request"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema_has_multi_action_field(schema: &Schema) -> bool {
|
|
||||||
if !matches!(schema.kind, SchemaKind::Object) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
["action", "mode", "operation", "type"]
|
|
||||||
.iter()
|
|
||||||
.any(|field_name| schema.fields.contains_key(*field_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn output_mapping_returns_broad_response(mapping: &MappingSet) -> bool {
|
|
||||||
mapping.rules.iter().any(|rule| {
|
|
||||||
let source = rule.source.trim();
|
|
||||||
let target = rule.target.trim();
|
|
||||||
matches!(source, "$.response" | "$.response.body" | "$.response.data")
|
|
||||||
|| matches!(target, "$.output" | "$")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_core::{
|
|
||||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel,
|
|
||||||
OperationStatus, Protocol, RestTarget, Target, ToolDescription,
|
|
||||||
};
|
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
|
||||||
use crank_registry::RegistryOperation;
|
|
||||||
use crank_schema::{Schema, SchemaKind};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
use super::import_guidance_warnings;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flags_generic_multi_action_delete() {
|
|
||||||
let operation = imported_delete_operation();
|
|
||||||
|
|
||||||
let warnings = import_guidance_warnings(&operation);
|
|
||||||
let joined = warnings.join("\n");
|
|
||||||
|
|
||||||
assert!(joined.contains("Имя инструмента слишком общее"));
|
|
||||||
assert!(joined.contains("несколькими действиями"));
|
|
||||||
assert!(joined.contains("Настройка результата"));
|
|
||||||
assert!(joined.contains("ключ идемпотентности"));
|
|
||||||
assert!(joined.contains("DELETE будет выполняться"));
|
|
||||||
assert!(joined.contains("сгруппируйте похожие endpoint-ы"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn imported_delete_operation() -> RegistryOperation {
|
|
||||||
Operation {
|
|
||||||
id: OperationId::new("op_imported_delete"),
|
|
||||||
name: "call_api".to_owned(),
|
|
||||||
display_name: "Call API".to_owned(),
|
|
||||||
category: "general".to_owned(),
|
|
||||||
protocol: Protocol::Rest,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
status: OperationStatus::Draft,
|
|
||||||
version: 1,
|
|
||||||
target: Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Delete,
|
|
||||||
path_template: "/items/{id}".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
}),
|
|
||||||
input_schema: Schema {
|
|
||||||
kind: SchemaKind::Object,
|
|
||||||
description: None,
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::from([("action".to_owned(), string_schema())]),
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
},
|
|
||||||
output_schema: Schema {
|
|
||||||
kind: SchemaKind::Object,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
},
|
|
||||||
input_mapping: MappingSet { rules: Vec::new() },
|
|
||||||
output_mapping: MappingSet {
|
|
||||||
rules: vec![MappingRule {
|
|
||||||
source: "$.response.body".to_owned(),
|
|
||||||
target: "$.output".to_owned(),
|
|
||||||
required: false,
|
|
||||||
default_value: None,
|
|
||||||
transform: None,
|
|
||||||
condition: None,
|
|
||||||
notes: None,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
execution_config: ExecutionConfig {
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
approval_policy: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
tool_description: ToolDescription {
|
|
||||||
title: "Call API".to_owned(),
|
|
||||||
description: "Calls API".to_owned(),
|
|
||||||
tags: Vec::new(),
|
|
||||||
examples: Vec::new(),
|
|
||||||
},
|
|
||||||
samples: None,
|
|
||||||
generated_draft: None,
|
|
||||||
config_export: None,
|
|
||||||
wizard_state: None,
|
|
||||||
created_at: OffsetDateTime::UNIX_EPOCH,
|
|
||||||
updated_at: OffsetDateTime::UNIX_EPOCH,
|
|
||||||
published_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_schema() -> Schema {
|
|
||||||
Schema {
|
|
||||||
kind: SchemaKind::String,
|
|
||||||
description: None,
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod dto;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod import_guidance;
|
|
||||||
pub mod pool_metrics;
|
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
pub mod reconciliation;
|
|
||||||
pub mod request_context;
|
pub mod request_context;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|||||||
+82
-441
@@ -1,240 +1,80 @@
|
|||||||
use std::{io, net::SocketAddr, process::ExitCode, time::Duration};
|
use std::{env, net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
use admin_api::{
|
use admin_api::{
|
||||||
app::build_app,
|
app::build_app,
|
||||||
auth::{AuthSettings, BootstrapAdminConfig},
|
auth::{AuthSettings, BootstrapAdminConfig},
|
||||||
pool_metrics::spawn_postgres_pool_metrics,
|
|
||||||
reconciliation::{open_reconciliation_store, spawn_artifact_reconciliation},
|
|
||||||
service::AdminServiceBuilder,
|
service::AdminServiceBuilder,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use crank_community_auth::PasswordIdentityProvider;
|
use crank_community_auth::PasswordIdentityProvider;
|
||||||
use crank_config::{
|
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||||
AdminProcessConfig, CacheBackend as ConfigCacheBackend, ConfigSource, DatabaseSettings,
|
|
||||||
DiagnosticCode, ObservabilitySettings, ProcessKind, parse_process,
|
|
||||||
};
|
|
||||||
use crank_core::CacheBackend;
|
|
||||||
use crank_observability::{
|
|
||||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
|
||||||
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
|
|
||||||
};
|
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||||
RuntimeLimits, SecretCrypto,
|
RuntimeLimits, SecretCrypto, community_default,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgConnectOptions;
|
use sqlx::postgres::PgConnectOptions;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::{info, warn};
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match main_result().await {
|
tracing_subscriber::fmt()
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
.with_env_filter(
|
||||||
Err(error) => {
|
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
|
||||||
eprintln!("{}", safe_startup_diagnostic(error.as_ref()));
|
)
|
||||||
ExitCode::FAILURE
|
.init();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String {
|
let storage_root = PathBuf::from(
|
||||||
let mut current = Some(error);
|
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||||
while let Some(cause) = current {
|
);
|
||||||
if let Some(config) = cause.downcast_ref::<crank_config::ConfigError>() {
|
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||||
return config.to_json();
|
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||||
}
|
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||||
if let Some(migration) = cause.downcast_ref::<crank_registry::MigrationError>() {
|
let pool_config = PostgresPoolConfig::from_env()?;
|
||||||
return serde_json::json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": migration.code(),
|
|
||||||
"stage": migration.stage(),
|
|
||||||
"version": migration.version(),
|
|
||||||
"recovery": migration.recovery(),
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
if let Some(crank_registry::RegistryError::Migration(migration)) =
|
|
||||||
cause.downcast_ref::<crank_registry::RegistryError>()
|
|
||||||
{
|
|
||||||
return serde_json::json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": migration.code(),
|
|
||||||
"stage": migration.stage(),
|
|
||||||
"version": migration.version(),
|
|
||||||
"recovery": migration.recovery(),
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) =
|
|
||||||
cause.downcast_ref::<crank_registry::RegistryError>()
|
|
||||||
{
|
|
||||||
return serde_json::json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": "master_key_identity_mismatch",
|
|
||||||
"stage": "startup.master_key_identity",
|
|
||||||
"version": epoch,
|
|
||||||
"recovery": "configure_same_master_key",
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
if cause
|
|
||||||
.downcast_ref::<crank_registry::RegistryError>()
|
|
||||||
.is_some_and(|error| {
|
|
||||||
matches!(
|
|
||||||
error,
|
|
||||||
crank_registry::RegistryError::InvalidMasterKeyIdentity
|
|
||||||
)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return serde_json::json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": "master_key_identity_invalid",
|
|
||||||
"stage": "startup.master_key_identity",
|
|
||||||
"version": null,
|
|
||||||
"recovery": "contact_operator",
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
current = cause.source();
|
|
||||||
}
|
|
||||||
serde_json::json!({
|
|
||||||
"status": "error",
|
|
||||||
"code": "startup_failed",
|
|
||||||
"stage": "startup",
|
|
||||||
"version": null,
|
|
||||||
"recovery": "contact_operator",
|
|
||||||
})
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main_result() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let effective = parse_process(ProcessKind::AdminApi, ConfigSource::from_os()?)?;
|
|
||||||
let config = effective
|
|
||||||
.admin()
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| io::Error::other("admin configuration projection is unavailable"))?;
|
|
||||||
preflight_config(&config)?;
|
|
||||||
let observability = init_observability(&config.observability)?;
|
|
||||||
for deprecation in effective.deprecations() {
|
|
||||||
warn!(
|
|
||||||
name: "config.deprecated",
|
|
||||||
field = deprecation.field,
|
|
||||||
source_class = deprecation.source_class,
|
|
||||||
replacement = deprecation.replacement,
|
|
||||||
removal_window = deprecation.removal_window,
|
|
||||||
"deprecated configuration accepted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut startup_completed = false;
|
|
||||||
let result = run(config, &observability, &mut startup_completed).await;
|
|
||||||
if result.is_err() {
|
|
||||||
capture_critical_error(if startup_completed {
|
|
||||||
CriticalErrorCategory::Internal
|
|
||||||
} else {
|
|
||||||
CriticalErrorCategory::Startup
|
|
||||||
});
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(
|
|
||||||
config: AdminProcessConfig,
|
|
||||||
observability: &ObservabilityLifecycle,
|
|
||||||
startup_completed: &mut bool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let metrics_config = MetricsConfig::new(
|
|
||||||
config.observability.metrics.enabled,
|
|
||||||
config.observability.metrics.bind_addr,
|
|
||||||
config
|
|
||||||
.observability
|
|
||||||
.metrics
|
|
||||||
.bearer_token
|
|
||||||
.as_ref()
|
|
||||||
.map(|token| token.expose_secret().to_owned()),
|
|
||||||
)?;
|
|
||||||
let metrics_enabled = metrics_config.enabled();
|
|
||||||
let pool_config = postgres_pool_config(&config.database)?;
|
|
||||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||||
database_options(&config.database)?,
|
database_options_from_env()?,
|
||||||
pool_config,
|
pool_config,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let metrics_server = if metrics_enabled {
|
|
||||||
Some(observability.metrics_surface(metrics_config).bind().await?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if metrics_enabled {
|
|
||||||
spawn_postgres_pool_metrics(registry.pool().clone());
|
|
||||||
}
|
|
||||||
let base_url = config
|
|
||||||
.runtime
|
|
||||||
.base_url
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "http://localhost:3000".to_owned());
|
|
||||||
let auth_settings = AuthSettings {
|
let auth_settings = AuthSettings {
|
||||||
session_secret: config.session_secret.expose_secret().to_owned(),
|
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||||
password_pepper: config.password_pepper.expose_secret().to_owned(),
|
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||||
session_ttl_hours: config.session_ttl_hours,
|
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.unwrap_or(24),
|
||||||
cookie_secure: base_url.starts_with("https://"),
|
cookie_secure: base_url.starts_with("https://"),
|
||||||
bootstrap_admin: BootstrapAdminConfig {
|
bootstrap_admin: BootstrapAdminConfig {
|
||||||
email: config.bootstrap_email.clone(),
|
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
|
||||||
password: config
|
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
|
||||||
.bootstrap_password
|
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
|
||||||
.as_ref()
|
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||||
.map(|password| password.expose_secret().to_owned())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
display_name: config.bootstrap_display_name.clone(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let runtime_limits = RuntimeLimits::try_new(
|
let runtime_limits = RuntimeLimits::from_env()?;
|
||||||
config.runtime.max_concurrent_unary,
|
let cache_config = RuntimeCacheConfig::from_env()?;
|
||||||
config.runtime.max_concurrent_sessions,
|
|
||||||
)?;
|
|
||||||
let cache_config = runtime_cache_config(&config)?;
|
|
||||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||||
let api_rate_limit = RequestRateLimitConfig::new(
|
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||||
config.rate_limit.requests_per_second,
|
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||||
config.rate_limit.burst,
|
let runtime = community_default()
|
||||||
)?;
|
|
||||||
let secret_crypto =
|
|
||||||
verified_startup_secret_crypto(®istry, config.runtime.master_key.expose_secret())
|
|
||||||
.await?;
|
|
||||||
let artifact_store = open_reconciliation_store(config.storage_root.clone()).await?;
|
|
||||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits(
|
|
||||||
config.runtime.outbound.allowed_hosts.clone(),
|
|
||||||
config.runtime.outbound.denied_hosts.clone(),
|
|
||||||
config.runtime.outbound.max_request_bytes,
|
|
||||||
config.runtime.outbound.max_response_bytes,
|
|
||||||
)?;
|
|
||||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
|
|
||||||
.with_limits(runtime_limits)
|
.with_limits(runtime_limits)
|
||||||
.with_response_cache(cache_stores.response.clone())
|
.with_response_cache(cache_stores.response.clone())
|
||||||
.with_coordination_store(cache_stores.coordination.clone())
|
|
||||||
.build();
|
.build();
|
||||||
let identity_provider =
|
let identity_provider =
|
||||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||||
let service = AdminServiceBuilder::new(
|
let service = AdminServiceBuilder::new(
|
||||||
registry.clone(),
|
registry,
|
||||||
config.storage_root.clone(),
|
storage_root,
|
||||||
auth_settings,
|
auth_settings,
|
||||||
secret_crypto,
|
secret_crypto,
|
||||||
runtime,
|
runtime,
|
||||||
)
|
)
|
||||||
.with_artifact_store(artifact_store.clone())
|
|
||||||
.with_public_base_url(base_url)
|
|
||||||
.with_outbound_http_policy(outbound_http_policy)
|
|
||||||
.with_external_reference_import(&config.external_references)?
|
|
||||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||||
.build();
|
.build();
|
||||||
if config.demo_seed {
|
service.bootstrap_admin_user().await?;
|
||||||
|
if env_flag("CRANK_DEMO_SEED") {
|
||||||
service.seed_demo_assets().await?;
|
service.seed_demo_assets().await?;
|
||||||
}
|
}
|
||||||
spawn_artifact_reconciliation(registry, artifact_store).await;
|
|
||||||
spawn_invocation_log_cleanup(service.clone(), config.invocation_log_retention_days);
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
service,
|
service,
|
||||||
api_rate_limiter: if cache_config.backend.is_external() {
|
api_rate_limiter: if cache_config.backend.is_external() {
|
||||||
@@ -242,15 +82,15 @@ async fn run(
|
|||||||
} else {
|
} else {
|
||||||
RequestRateLimiter::new(api_rate_limit)
|
RequestRateLimiter::new(api_rate_limit)
|
||||||
},
|
},
|
||||||
trusted_proxy_ips: config.trusted_proxy_ips.clone(),
|
|
||||||
};
|
};
|
||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(socket_addr).await?;
|
||||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
name: "admin.postgres_pool.configured",
|
|
||||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||||
|
runtime_max_concurrent_window = runtime_limits.max_concurrent_window,
|
||||||
|
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
|
||||||
|
runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs,
|
||||||
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||||
admin_rate_limit_burst = api_rate_limit.burst,
|
admin_rate_limit_burst = api_rate_limit.burst,
|
||||||
cache_backend = %cache_config.backend,
|
cache_backend = %cache_config.backend,
|
||||||
@@ -259,257 +99,58 @@ async fn run(
|
|||||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||||
invocation_log_retention_days = config.invocation_log_retention_days,
|
|
||||||
"postgres pool configured"
|
"postgres pool configured"
|
||||||
);
|
);
|
||||||
info!(name: "admin.server.listening", bind_address = %config.bind_addr, "admin-api listening");
|
info!("admin-api listening on {}", socket_addr);
|
||||||
*startup_completed = true;
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
if let Some(metrics_server) = metrics_server {
|
|
||||||
tokio::select! {
|
|
||||||
result = axum::serve(listener, make_service) => result?,
|
|
||||||
result = metrics_server.serve() => result?,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
axum::serve(listener, make_service).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_observability(
|
fn env_flag(name: &str) -> bool {
|
||||||
config: &ObservabilitySettings,
|
matches!(
|
||||||
) -> Result<ObservabilityLifecycle, Box<dyn std::error::Error>> {
|
env::var(name)
|
||||||
let identity = ServiceIdentity::try_new(
|
.ok()
|
||||||
"admin-api",
|
.as_deref()
|
||||||
env!("CARGO_PKG_VERSION"),
|
.map(str::to_ascii_lowercase)
|
||||||
config.environment.clone(),
|
.as_deref(),
|
||||||
)?;
|
Some("1" | "true" | "yes" | "on")
|
||||||
let base = ObservabilityConfig::try_new(
|
|
||||||
identity,
|
|
||||||
config.log_filter.clone(),
|
|
||||||
RedactionLimits::default(),
|
|
||||||
)?;
|
|
||||||
let sentry = SentryConfig::parse(
|
|
||||||
config
|
|
||||||
.sentry_dsn
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.expose_secret()),
|
|
||||||
)?;
|
|
||||||
let otlp = otlp_config(config)?;
|
|
||||||
Ok(ObservabilityLifecycle::init_with_exporters(
|
|
||||||
base, sentry, otlp,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::ConfigError> {
|
|
||||||
let invalid = |field| crank_config::ConfigError::single(DiagnosticCode::InvalidType, field);
|
|
||||||
database_options(&config.database).map_err(|_| invalid("database.source"))?;
|
|
||||||
postgres_pool_config(&config.database).map_err(|_| invalid("database.pool"))?;
|
|
||||||
MetricsConfig::new(
|
|
||||||
config.observability.metrics.enabled,
|
|
||||||
config.observability.metrics.bind_addr,
|
|
||||||
config
|
|
||||||
.observability
|
|
||||||
.metrics
|
|
||||||
.bearer_token
|
|
||||||
.as_ref()
|
|
||||||
.map(|v| v.expose_secret().to_owned()),
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("observability.metrics"))?;
|
|
||||||
RuntimeLimits::try_new(
|
|
||||||
config.runtime.max_concurrent_unary,
|
|
||||||
config.runtime.max_concurrent_sessions,
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("runtime.limits"))?;
|
|
||||||
runtime_cache_config(config).map_err(|_| invalid("cache"))?;
|
|
||||||
RequestRateLimitConfig::new(
|
|
||||||
config.rate_limit.requests_per_second,
|
|
||||||
config.rate_limit.burst,
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("admin.rate_limit"))?;
|
|
||||||
SecretCrypto::new(config.runtime.master_key.expose_secret())
|
|
||||||
.map_err(|_| invalid("runtime.master_key"))?;
|
|
||||||
crank_runtime::OutboundHttpPolicy::try_new_with_limits(
|
|
||||||
config.runtime.outbound.allowed_hosts.clone(),
|
|
||||||
config.runtime.outbound.denied_hosts.clone(),
|
|
||||||
config.runtime.outbound.max_request_bytes,
|
|
||||||
config.runtime.outbound.max_response_bytes,
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("runtime.outbound"))?;
|
|
||||||
let identity = ServiceIdentity::try_new(
|
|
||||||
"admin-api",
|
|
||||||
env!("CARGO_PKG_VERSION"),
|
|
||||||
config.observability.environment.clone(),
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("observability.environment"))?;
|
|
||||||
ObservabilityConfig::try_new(
|
|
||||||
identity,
|
|
||||||
config.observability.log_filter.clone(),
|
|
||||||
RedactionLimits::default(),
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("observability.log_filter"))?;
|
|
||||||
SentryConfig::parse(
|
|
||||||
config
|
|
||||||
.observability
|
|
||||||
.sentry_dsn
|
|
||||||
.as_ref()
|
|
||||||
.map(|v| v.expose_secret()),
|
|
||||||
)
|
|
||||||
.map_err(|_| invalid("observability.sentry_dsn"))?;
|
|
||||||
otlp_config(&config.observability).map_err(|_| invalid("observability.otlp"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verified_startup_secret_crypto(
|
|
||||||
registry: &PostgresRegistry,
|
|
||||||
master_key: &str,
|
|
||||||
) -> Result<SecretCrypto, Box<dyn std::error::Error>> {
|
|
||||||
let active = registry.active_master_key_identity().await?;
|
|
||||||
let secret_crypto = if let Some(identity) = active {
|
|
||||||
SecretCrypto::with_epoch(master_key, identity.epoch)?
|
|
||||||
} else {
|
|
||||||
let crypto = SecretCrypto::new(master_key)?;
|
|
||||||
let mut after_secret_id: Option<String> = None;
|
|
||||||
let mut after_version: Option<u32> = None;
|
|
||||||
loop {
|
|
||||||
let versions = registry
|
|
||||||
.list_secret_versions_for_master_key_epoch_page(
|
|
||||||
1,
|
|
||||||
after_secret_id.as_deref(),
|
|
||||||
after_version,
|
|
||||||
1_000,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if versions.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for version in versions {
|
|
||||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
|
||||||
after_version = Some(version.secret_version.version);
|
|
||||||
crypto.decrypt_for_epoch(
|
|
||||||
&version.secret_version.key_version,
|
|
||||||
version.master_key_epoch,
|
|
||||||
&version.secret_version.ciphertext,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crypto
|
|
||||||
};
|
|
||||||
let master_key_observed_at = time::OffsetDateTime::now_utc();
|
|
||||||
registry
|
|
||||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
|
||||||
epoch: secret_crypto.master_key_epoch(),
|
|
||||||
fingerprint: secret_crypto.master_key_fingerprint(),
|
|
||||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
|
||||||
observed_at: &master_key_observed_at,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(secret_crypto)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn otlp_config(
|
|
||||||
config: &ObservabilitySettings,
|
|
||||||
) -> Result<OtlpTraceConfig, crank_observability::OtlpTraceConfigError> {
|
|
||||||
let values = &config.otlp;
|
|
||||||
OtlpTraceConfig::from_values(
|
|
||||||
values.endpoint.clone(),
|
|
||||||
values.traces_endpoint.clone(),
|
|
||||||
values.protocol.clone(),
|
|
||||||
values.traces_protocol.clone(),
|
|
||||||
values.timeout.clone(),
|
|
||||||
values.traces_timeout.clone(),
|
|
||||||
values
|
|
||||||
.headers
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.expose_secret().to_owned()),
|
|
||||||
values
|
|
||||||
.traces_headers
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.expose_secret().to_owned()),
|
|
||||||
values.max_queue_size,
|
|
||||||
values.max_export_batch_size,
|
|
||||||
values.schedule_delay.clone(),
|
|
||||||
values.export_timeout.clone(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn postgres_pool_config(
|
fn admin_api_rate_limit_config_from_env()
|
||||||
config: &DatabaseSettings,
|
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> {
|
||||||
) -> Result<PostgresPoolConfig, crank_registry::PostgresPoolConfigError> {
|
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS")
|
||||||
PostgresPoolConfig::try_new(
|
.ok()
|
||||||
config.pool.max_connections,
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
config.pool.min_connections,
|
.unwrap_or(30);
|
||||||
config.pool.acquire_timeout_ms,
|
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST")
|
||||||
config.pool.idle_timeout_ms,
|
.ok()
|
||||||
config.pool.max_lifetime_ms,
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
)
|
.unwrap_or(60);
|
||||||
|
|
||||||
|
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn database_options(
|
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||||
config: &DatabaseSettings,
|
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
|
||||||
) -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
return Ok(database_url.parse::<PgConnectOptions>()?);
|
||||||
if let Some(url) = &config.url {
|
|
||||||
return url
|
|
||||||
.expose_secret()
|
|
||||||
.parse::<PgConnectOptions>()
|
|
||||||
.map_err(|_| io::Error::other("database URL is invalid").into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
|
||||||
|
let port = env::var("POSTGRES_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u16>().ok())
|
||||||
|
.unwrap_or(5432);
|
||||||
|
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
|
||||||
|
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
|
||||||
|
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
|
||||||
|
|
||||||
Ok(PgConnectOptions::new()
|
Ok(PgConnectOptions::new()
|
||||||
.host(&config.host)
|
.host(&host)
|
||||||
.port(config.port)
|
.port(port)
|
||||||
.database(&config.database)
|
.database(&database)
|
||||||
.username(&config.username)
|
.username(&username)
|
||||||
.password(config.password.expose_secret()))
|
.password(&password))
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_cache_config(
|
|
||||||
config: &AdminProcessConfig,
|
|
||||||
) -> Result<RuntimeCacheConfig, crank_runtime::RuntimeCacheConfigError> {
|
|
||||||
RuntimeCacheConfig::try_new(
|
|
||||||
match config.runtime.cache.backend {
|
|
||||||
ConfigCacheBackend::Memory => CacheBackend::Memory,
|
|
||||||
ConfigCacheBackend::Valkey => CacheBackend::Valkey,
|
|
||||||
ConfigCacheBackend::Redis => CacheBackend::Redis,
|
|
||||||
},
|
|
||||||
config
|
|
||||||
.runtime
|
|
||||||
.cache
|
|
||||||
.url
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| value.expose_secret().to_owned()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, retention_days: i64) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(60 * 60));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
|
|
||||||
match service.cleanup_invocation_logs_before(cutoff).await {
|
|
||||||
Ok(outcome) if outcome.deleted_records > 0 => info!(
|
|
||||||
name: "admin.invocation_log_cleanup.completed",
|
|
||||||
status = ?outcome.status,
|
|
||||||
removed = outcome.deleted_records,
|
|
||||||
requested_cutoff = %outcome.policy.requested_cutoff,
|
|
||||||
effective_cutoff = %outcome.policy.effective_cutoff,
|
|
||||||
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
|
|
||||||
"expired invocation logs removed"
|
|
||||||
),
|
|
||||||
Ok(outcome) => info!(
|
|
||||||
name: "admin.invocation_log_cleanup.noop",
|
|
||||||
status = ?outcome.status,
|
|
||||||
requested_cutoff = %outcome.policy.requested_cutoff,
|
|
||||||
effective_cutoff = %outcome.policy.effective_cutoff,
|
|
||||||
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
|
|
||||||
"no expired invocation logs removed"
|
|
||||||
),
|
|
||||||
Err(_) => warn!(
|
|
||||||
name: "admin.invocation_log_cleanup.failed",
|
|
||||||
error_category = "registry_cleanup",
|
|
||||||
"failed to remove expired invocation logs"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
pub fn spawn_postgres_pool_metrics(pool: PgPool) -> tokio::task::JoinHandle<()> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
crank_observability::record_db_pool_connections(pool.size(), pool.num_idle());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,26 @@
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Request, State},
|
extract::{Request, State},
|
||||||
http::HeaderMap,
|
http::header::{COOKIE, HeaderMap},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
use crank_runtime::RateLimitRejection;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ClientIdentityBucket(pub String);
|
|
||||||
|
|
||||||
pub async fn apply_api_rate_limit(
|
pub async fn apply_api_rate_limit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
mut request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let peer_ip = request
|
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||||
.extensions()
|
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||||
.get::<ConnectInfo<SocketAddr>>()
|
return Err(ApiError::rate_limited_with_context(
|
||||||
.map(|ConnectInfo(address)| address.ip());
|
"request rate limit exceeded",
|
||||||
let key = client_rate_limit_key(
|
rejection_context(rejection),
|
||||||
request.headers(),
|
));
|
||||||
request.uri().path(),
|
|
||||||
peer_ip,
|
|
||||||
&state.trusted_proxy_ips,
|
|
||||||
);
|
|
||||||
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
|
||||||
return match error {
|
|
||||||
RateLimitCheckError::Rejected(rejection) => Err(ApiError::rate_limited_with_context(
|
|
||||||
"request rate limit exceeded",
|
|
||||||
rejection_context(rejection),
|
|
||||||
)),
|
|
||||||
RateLimitCheckError::StoreUnavailable => {
|
|
||||||
Err(ApiError::internal("rate limit service unavailable"))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.extensions_mut().insert(ClientIdentityBucket(key));
|
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,67 +30,56 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn client_rate_limit_key(
|
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||||
headers: &HeaderMap,
|
if let Some(session_id) = session_id_from_headers(headers) {
|
||||||
path: &str,
|
return format!("session:{session_id}");
|
||||||
peer_ip: Option<IpAddr>,
|
|
||||||
trusted_proxy_ips: &[IpAddr],
|
|
||||||
) -> String {
|
|
||||||
if peer_ip.is_some_and(|peer_ip| trusted_proxy_ips.contains(&peer_ip))
|
|
||||||
&& let Some(client_ip) = forwarded_client_ip(headers)
|
|
||||||
{
|
|
||||||
return format!("ip:{client_ip}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(peer_ip) = peer_ip {
|
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
||||||
return format!("ip:{peer_ip}");
|
let ip = forwarded_for
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
return format!("ip:{ip}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
||||||
|
return format!("ip:{real_ip}");
|
||||||
}
|
}
|
||||||
|
|
||||||
format!("anonymous:{path}")
|
format!("anonymous:{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the client IP from proxy headers after the immediate peer was
|
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
/// matched against the trusted-proxy allowlist.
|
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
||||||
///
|
for part in cookies.split(';') {
|
||||||
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
let (name, value) = part.trim().split_once('=')?;
|
||||||
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
if name != SESSION_COOKIE_NAME {
|
||||||
/// peer, so the last entry is the trustworthy hop; taking the first entry (as
|
continue;
|
||||||
/// naive implementations do) would let a client spoof its address by sending a
|
}
|
||||||
/// pre-populated header.
|
let (session_id, _) = value.split_once('.')?;
|
||||||
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
if !session_id.is_empty() {
|
||||||
if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
|
return Some(session_id.to_owned());
|
||||||
return Some(real_ip);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header_value(headers, "x-forwarded-for")?
|
None
|
||||||
.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.rfind(|value| !value.is_empty())
|
|
||||||
.and_then(parse_ip)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||||
headers.get(name)?.to_str().ok().map(str::trim)
|
headers.get(name)?.to_str().ok().map(str::trim)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ip(value: &str) -> Option<IpAddr> {
|
|
||||||
value.parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
|
||||||
|
|
||||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||||
|
|
||||||
use super::client_rate_limit_key;
|
use super::rate_limit_key;
|
||||||
|
|
||||||
fn peer() -> Option<IpAddr> {
|
|
||||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unverified_session_cookie_cannot_change_client_key() {
|
fn keys_by_session_cookie_first() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
COOKIE,
|
COOKIE,
|
||||||
@@ -120,102 +88,19 @@ mod tests {
|
|||||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
rate_limit_key(&headers, "/api/auth/login"),
|
||||||
"ip:10.0.0.5"
|
"session:sess_123"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_forwarded_headers_when_untrusted() {
|
fn falls_back_to_forwarded_ip() {
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
|
||||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[]),
|
|
||||||
"ip:203.0.113.7"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_forwarded_headers_from_unlisted_peer() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(
|
|
||||||
&headers,
|
|
||||||
"/api/auth/login",
|
|
||||||
peer(),
|
|
||||||
&[IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))]
|
|
||||||
),
|
|
||||||
"ip:203.0.113.7"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefers_real_ip_when_trusted() {
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||||
);
|
|
||||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
|
||||||
"ip:10.0.0.9"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uses_last_forwarded_hop_when_trusted() {
|
|
||||||
// A client can prepend spoofed entries; the trusted proxy appends the
|
|
||||||
// real peer, so the last entry is authoritative.
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
"x-forwarded-for",
|
|
||||||
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
|
||||||
"ip:10.0.0.6"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_peer_ip_without_headers() {
|
|
||||||
let headers = HeaderMap::new();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
|
||||||
"ip:203.0.113.7"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_invalid_forwarded_ip_values() {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
|
|
||||||
headers.insert(
|
|
||||||
"x-forwarded-for",
|
|
||||||
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
|
||||||
"ip:203.0.113.7"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_path_without_peer() {
|
|
||||||
let headers = HeaderMap::new();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
|
|
||||||
"anonymous:/api/auth/login"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,944 +0,0 @@
|
|||||||
use std::{
|
|
||||||
path::PathBuf,
|
|
||||||
sync::Arc,
|
|
||||||
time::{Duration, SystemTime},
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crank_artifacts::{
|
|
||||||
ArtifactError, ArtifactStore, ReconciliationCursor, ReconciliationMutation,
|
|
||||||
ReconciliationNamespace, ReconciliationPresence, ReconciliationRegistration,
|
|
||||||
ReconciliationScanStop, TempScanCursor,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
ArtifactClaimFinalization, ArtifactClaimFinalizeOutcome, ArtifactClaimOutcome,
|
|
||||||
ArtifactClaimRecheckOutcome, ArtifactClaimToken, ArtifactExpiredClaimProbe,
|
|
||||||
ClaimArtifactReconciliationRequest, ClaimExpiredArtifactReconciliationRequest,
|
|
||||||
PostgresRegistry,
|
|
||||||
};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tokio::time::MissedTickBehavior;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
pub const RECONCILIATION_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
|
||||||
pub const RECONCILIATION_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
|
|
||||||
pub const RECONCILIATION_LEASE: Duration = Duration::from_secs(5 * 60);
|
|
||||||
pub const RECONCILIATION_TRAVERSAL_LIMIT: usize = 4_096;
|
|
||||||
pub const RECONCILIATION_CANDIDATE_LIMIT: usize = 32;
|
|
||||||
pub const RECONCILIATION_MUTATION_LIMIT: usize = 32;
|
|
||||||
pub const RECONCILIATION_TICK_DEADLINE: Duration = Duration::from_secs(30);
|
|
||||||
pub const TEMP_CLEANUP_GRACE: Duration = Duration::from_secs(60 * 60);
|
|
||||||
pub const TEMP_CLEANUP_SCAN_LIMIT: usize = 512;
|
|
||||||
pub const TEMP_CLEANUP_DELETE_LIMIT: usize = 16;
|
|
||||||
pub const IMPORT_JOB_CLEANUP_LIMIT: u32 = 128;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
enum Phase {
|
|
||||||
Recovery,
|
|
||||||
Sweep,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
struct StepLimits {
|
|
||||||
traversal_syscalls: usize,
|
|
||||||
candidates: usize,
|
|
||||||
mutations: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Step<C> {
|
|
||||||
cursor: Option<C>,
|
|
||||||
scan_complete: bool,
|
|
||||||
traversal_syscalls: usize,
|
|
||||||
candidates: usize,
|
|
||||||
mutation_attempts: usize,
|
|
||||||
classifications: ReconciliationClassificationCounters,
|
|
||||||
physical_mutation_possible: bool,
|
|
||||||
retryable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bounded, identity-free classifications observed during one or more scans.
|
|
||||||
///
|
|
||||||
/// The fixed fields are the complete telemetry vocabulary: no digest, path,
|
|
||||||
/// token, source or workspace value can be attached to an item classification.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct ReconciliationClassificationCounters {
|
|
||||||
pub scanner_malformed: usize,
|
|
||||||
pub scanner_unsafe: usize,
|
|
||||||
pub recovery_present: usize,
|
|
||||||
pub recovery_safety: usize,
|
|
||||||
pub recovery_retryable: usize,
|
|
||||||
pub registration_integrity: usize,
|
|
||||||
pub registration_safety: usize,
|
|
||||||
pub registration_retryable: usize,
|
|
||||||
pub claim_metadata_conflict: usize,
|
|
||||||
pub mutation_integrity: usize,
|
|
||||||
pub mutation_safety: usize,
|
|
||||||
pub mutation_retryable: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReconciliationClassificationCounters {
|
|
||||||
fn checked_add(self, other: Self) -> Option<Self> {
|
|
||||||
Some(Self {
|
|
||||||
scanner_malformed: self
|
|
||||||
.scanner_malformed
|
|
||||||
.checked_add(other.scanner_malformed)?,
|
|
||||||
scanner_unsafe: self.scanner_unsafe.checked_add(other.scanner_unsafe)?,
|
|
||||||
recovery_present: self.recovery_present.checked_add(other.recovery_present)?,
|
|
||||||
recovery_safety: self.recovery_safety.checked_add(other.recovery_safety)?,
|
|
||||||
recovery_retryable: self
|
|
||||||
.recovery_retryable
|
|
||||||
.checked_add(other.recovery_retryable)?,
|
|
||||||
registration_integrity: self
|
|
||||||
.registration_integrity
|
|
||||||
.checked_add(other.registration_integrity)?,
|
|
||||||
registration_safety: self
|
|
||||||
.registration_safety
|
|
||||||
.checked_add(other.registration_safety)?,
|
|
||||||
registration_retryable: self
|
|
||||||
.registration_retryable
|
|
||||||
.checked_add(other.registration_retryable)?,
|
|
||||||
claim_metadata_conflict: self
|
|
||||||
.claim_metadata_conflict
|
|
||||||
.checked_add(other.claim_metadata_conflict)?,
|
|
||||||
mutation_integrity: self
|
|
||||||
.mutation_integrity
|
|
||||||
.checked_add(other.mutation_integrity)?,
|
|
||||||
mutation_safety: self.mutation_safety.checked_add(other.mutation_safety)?,
|
|
||||||
mutation_retryable: self
|
|
||||||
.mutation_retryable
|
|
||||||
.checked_add(other.mutation_retryable)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_registration_error(&mut self, error: ArtifactError) -> bool {
|
|
||||||
match error {
|
|
||||||
ArtifactError::Integrity
|
|
||||||
| ArtifactError::EmptySource
|
|
||||||
| ArtifactError::SourceTooLarge => {
|
|
||||||
self.registration_integrity += 1;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
ArtifactError::UnsafeRoot
|
|
||||||
| ArtifactError::InvalidReference
|
|
||||||
| ArtifactError::NotFound => {
|
|
||||||
self.registration_safety += 1;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
ArtifactError::Storage => {
|
|
||||||
self.registration_retryable += 1;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_mutation_error(&mut self, error: ArtifactError) -> bool {
|
|
||||||
match error {
|
|
||||||
ArtifactError::Integrity
|
|
||||||
| ArtifactError::EmptySource
|
|
||||||
| ArtifactError::SourceTooLarge => self.mutation_integrity += 1,
|
|
||||||
ArtifactError::UnsafeRoot
|
|
||||||
| ArtifactError::InvalidReference
|
|
||||||
| ArtifactError::NotFound => self.mutation_safety += 1,
|
|
||||||
ArtifactError::Storage => self.mutation_retryable += 1,
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_recovery_presence(&mut self, presence: ReconciliationPresence) -> bool {
|
|
||||||
match presence {
|
|
||||||
ReconciliationPresence::Final | ReconciliationPresence::Quarantine => {
|
|
||||||
self.recovery_present += 1;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
ReconciliationPresence::Unsafe => {
|
|
||||||
self.recovery_safety += 1;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
ReconciliationPresence::Retryable => {
|
|
||||||
self.recovery_retryable += 1;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
ReconciliationPresence::Absent => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_mutation_presence(&mut self, presence: ReconciliationPresence) -> bool {
|
|
||||||
match presence {
|
|
||||||
ReconciliationPresence::Absent => false,
|
|
||||||
ReconciliationPresence::Unsafe => {
|
|
||||||
self.mutation_safety += 1;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
ReconciliationPresence::Final
|
|
||||||
| ReconciliationPresence::Quarantine
|
|
||||||
| ReconciliationPresence::Retryable => {
|
|
||||||
self.mutation_retryable += 1;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct ReconciliationTickReport {
|
|
||||||
pub traversal_syscalls: usize,
|
|
||||||
pub candidates: usize,
|
|
||||||
pub mutation_attempts: usize,
|
|
||||||
pub classifications: ReconciliationClassificationCounters,
|
|
||||||
pub recovery_completed: bool,
|
|
||||||
pub cycle_completed: bool,
|
|
||||||
pub retryable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
|
||||||
pub enum ReconciliationCoordinatorError {
|
|
||||||
#[error("artifact reconciliation backend is unavailable")]
|
|
||||||
Backend,
|
|
||||||
#[error("artifact reconciliation backend exceeded its bounded contract")]
|
|
||||||
Bounds,
|
|
||||||
#[error("artifact reconciliation backend exceeded its deadline")]
|
|
||||||
Deadline,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
trait ReconciliationBackend: Send + Sync {
|
|
||||||
type Cursor: Send;
|
|
||||||
|
|
||||||
async fn step(
|
|
||||||
&self,
|
|
||||||
phase: Phase,
|
|
||||||
cursor: Option<Self::Cursor>,
|
|
||||||
limits: StepLimits,
|
|
||||||
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReconciliationCoordinator<B: ReconciliationBackend> {
|
|
||||||
backend: B,
|
|
||||||
phase: Phase,
|
|
||||||
cursor: Option<B::Cursor>,
|
|
||||||
tick_limits: StepLimits,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct PostgresReconciliationBackend {
|
|
||||||
registry: PostgresRegistry,
|
|
||||||
store: Arc<ArtifactStore>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostgresReconciliationCursor {
|
|
||||||
ExpiredClaim(ArtifactExpiredClaimProbe),
|
|
||||||
Filesystem(ReconciliationCursor),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for PostgresReconciliationCursor {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
formatter.write_str("PostgresReconciliationCursor(..)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresReconciliationBackend {
|
|
||||||
fn new(registry: PostgresRegistry, store: Arc<ArtifactStore>) -> Self {
|
|
||||||
Self { registry, store }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn database_now(&self) -> Result<OffsetDateTime, ReconciliationCoordinatorError> {
|
|
||||||
self.registry
|
|
||||||
.artifact_reconciliation_now()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn presence(
|
|
||||||
&self,
|
|
||||||
artifact_ref: crank_artifacts::ArtifactRef,
|
|
||||||
) -> Result<ReconciliationPresence, ReconciliationCoordinatorError> {
|
|
||||||
let store = Arc::clone(&self.store);
|
|
||||||
let result =
|
|
||||||
tokio::task::spawn_blocking(move || store.reconciliation_presence(&artifact_ref))
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
Ok(match result {
|
|
||||||
Ok(presence) => presence,
|
|
||||||
Err(ArtifactError::Storage) => ReconciliationPresence::Retryable,
|
|
||||||
Err(_) => ReconciliationPresence::Unsafe,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconciliation_lease_expires_at(claimed_at: OffsetDateTime) -> OffsetDateTime {
|
|
||||||
claimed_at
|
|
||||||
+ time::Duration::seconds(
|
|
||||||
i64::try_from(RECONCILIATION_LEASE.as_secs())
|
|
||||||
.expect("the fixed reconciliation lease fits i64"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<B: ReconciliationBackend> ReconciliationCoordinator<B> {
|
|
||||||
fn new(backend: B) -> Self {
|
|
||||||
Self {
|
|
||||||
backend,
|
|
||||||
phase: Phase::Recovery,
|
|
||||||
cursor: None,
|
|
||||||
tick_limits: StepLimits {
|
|
||||||
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT,
|
|
||||||
candidates: RECONCILIATION_CANDIDATE_LIMIT,
|
|
||||||
mutations: RECONCILIATION_MUTATION_LIMIT,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn with_tick_limits(backend: B, tick_limits: StepLimits) -> Self {
|
|
||||||
Self {
|
|
||||||
backend,
|
|
||||||
phase: Phase::Recovery,
|
|
||||||
cursor: None,
|
|
||||||
tick_limits,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tick(&mut self) -> Result<ReconciliationTickReport, ReconciliationCoordinatorError> {
|
|
||||||
let mut report = ReconciliationTickReport::default();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let limits = StepLimits {
|
|
||||||
traversal_syscalls: self
|
|
||||||
.tick_limits
|
|
||||||
.traversal_syscalls
|
|
||||||
.saturating_sub(report.traversal_syscalls),
|
|
||||||
candidates: self
|
|
||||||
.tick_limits
|
|
||||||
.candidates
|
|
||||||
.saturating_sub(report.candidates),
|
|
||||||
mutations: self
|
|
||||||
.tick_limits
|
|
||||||
.mutations
|
|
||||||
.saturating_sub(report.mutation_attempts),
|
|
||||||
};
|
|
||||||
if limits.traversal_syscalls == 0 || limits.candidates == 0 || limits.mutations == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let step = match self
|
|
||||||
.backend
|
|
||||||
.step(self.phase, self.cursor.take(), limits)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(step) => step,
|
|
||||||
Err(error) => {
|
|
||||||
self.cursor = None;
|
|
||||||
self.phase = Phase::Recovery;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if step.traversal_syscalls > limits.traversal_syscalls
|
|
||||||
|| step.candidates > limits.candidates
|
|
||||||
|| step.mutation_attempts > limits.mutations
|
|
||||||
|| step.mutation_attempts > step.candidates
|
|
||||||
{
|
|
||||||
self.cursor = None;
|
|
||||||
self.phase = Phase::Recovery;
|
|
||||||
return Err(ReconciliationCoordinatorError::Bounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
report.traversal_syscalls += step.traversal_syscalls;
|
|
||||||
report.candidates += step.candidates;
|
|
||||||
report.mutation_attempts += step.mutation_attempts;
|
|
||||||
report.classifications = report
|
|
||||||
.classifications
|
|
||||||
.checked_add(step.classifications)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
self.cursor = None;
|
|
||||||
self.phase = Phase::Recovery;
|
|
||||||
ReconciliationCoordinatorError::Bounds
|
|
||||||
})?;
|
|
||||||
report.retryable |= step.retryable;
|
|
||||||
|
|
||||||
let made_progress =
|
|
||||||
step.traversal_syscalls != 0 || step.candidates != 0 || step.mutation_attempts != 0;
|
|
||||||
if step.physical_mutation_possible {
|
|
||||||
// A cursor is valid only while the namespace is unchanged. A
|
|
||||||
// possible rename/unlink includes ambiguous fsync outcomes.
|
|
||||||
drop(step.cursor);
|
|
||||||
self.cursor = None;
|
|
||||||
self.phase = Phase::Recovery;
|
|
||||||
} else {
|
|
||||||
self.cursor = step.cursor;
|
|
||||||
if step.scan_complete {
|
|
||||||
self.cursor = None;
|
|
||||||
match self.phase {
|
|
||||||
Phase::Recovery => {
|
|
||||||
report.recovery_completed = true;
|
|
||||||
self.phase = Phase::Sweep;
|
|
||||||
}
|
|
||||||
Phase::Sweep => {
|
|
||||||
report.cycle_completed = true;
|
|
||||||
self.phase = Phase::Recovery;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if step.retryable || (!made_progress && !step.scan_complete) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ReconciliationBackend for PostgresReconciliationBackend {
|
|
||||||
type Cursor = PostgresReconciliationCursor;
|
|
||||||
|
|
||||||
async fn step(
|
|
||||||
&self,
|
|
||||||
phase: Phase,
|
|
||||||
cursor: Option<Self::Cursor>,
|
|
||||||
limits: StepLimits,
|
|
||||||
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError> {
|
|
||||||
let mut recovered_candidates = 0;
|
|
||||||
let mut classifications = ReconciliationClassificationCounters::default();
|
|
||||||
let mut recovery_retryable = false;
|
|
||||||
let mut filesystem_cursor = None;
|
|
||||||
if phase == Phase::Recovery {
|
|
||||||
let after = match cursor {
|
|
||||||
Some(PostgresReconciliationCursor::ExpiredClaim(probe)) => Some(probe),
|
|
||||||
Some(PostgresReconciliationCursor::Filesystem(cursor)) => {
|
|
||||||
filesystem_cursor = Some(cursor);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
if filesystem_cursor.is_none() {
|
|
||||||
let database_now = self.database_now().await?;
|
|
||||||
let probe_limit = u32::try_from(limits.candidates).unwrap_or(u32::MAX);
|
|
||||||
let probes = self
|
|
||||||
.registry
|
|
||||||
.list_expired_artifact_reconciliation_probes_after(
|
|
||||||
database_now,
|
|
||||||
after.as_ref(),
|
|
||||||
probe_limit,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
let page_full = probes.len() == probe_limit as usize;
|
|
||||||
let mut last_processed = after;
|
|
||||||
for probe in probes {
|
|
||||||
recovered_candidates += 1;
|
|
||||||
last_processed = Some(probe.clone());
|
|
||||||
let presence = self.presence(probe.artifact_ref().clone()).await?;
|
|
||||||
if presence != ReconciliationPresence::Absent {
|
|
||||||
recovery_retryable |= classifications.record_recovery_presence(presence);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let claimed_at = self.database_now().await?;
|
|
||||||
let claim = self
|
|
||||||
.registry
|
|
||||||
.claim_expired_artifact_reconciliation(
|
|
||||||
&probe,
|
|
||||||
ClaimExpiredArtifactReconciliationRequest {
|
|
||||||
token: ArtifactClaimToken::generate(),
|
|
||||||
claimed_at,
|
|
||||||
lease_expires_at: reconciliation_lease_expires_at(claimed_at),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
let Some(claim) = claim else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recheck after the DB CAS. A concurrent filesystem put
|
|
||||||
// does not consult PostgreSQL, so the first probe alone
|
|
||||||
// cannot prove absence at finalization time.
|
|
||||||
let presence = self.presence(claim.artifact_ref().clone()).await?;
|
|
||||||
if presence != ReconciliationPresence::Absent {
|
|
||||||
classifications.record_recovery_presence(presence);
|
|
||||||
recovery_retryable = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let recovered_at = self.database_now().await?;
|
|
||||||
let result = self
|
|
||||||
.registry
|
|
||||||
.recover_artifact_reconciliation_claim(
|
|
||||||
&claim,
|
|
||||||
ArtifactClaimFinalization::AlreadyAbsent,
|
|
||||||
recovered_at,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let recovered = matches!(result, Ok(ArtifactClaimFinalizeOutcome::Unavailable));
|
|
||||||
if recovered {
|
|
||||||
let presence = self.presence(claim.artifact_ref().clone()).await?;
|
|
||||||
if presence != ReconciliationPresence::Absent {
|
|
||||||
classifications.record_recovery_presence(presence);
|
|
||||||
recovery_retryable = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(Step {
|
|
||||||
cursor: last_processed.map(PostgresReconciliationCursor::ExpiredClaim),
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 0,
|
|
||||||
candidates: recovered_candidates,
|
|
||||||
mutation_attempts: 1,
|
|
||||||
classifications,
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: recovery_retryable || !recovered,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if page_full {
|
|
||||||
return Ok(Step {
|
|
||||||
cursor: last_processed.map(PostgresReconciliationCursor::ExpiredClaim),
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 0,
|
|
||||||
candidates: recovered_candidates,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications,
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: recovery_retryable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(PostgresReconciliationCursor::Filesystem(cursor)) = cursor {
|
|
||||||
filesystem_cursor = Some(cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
let scan_candidate_limit = limits.candidates.saturating_sub(recovered_candidates);
|
|
||||||
if scan_candidate_limit == 0 {
|
|
||||||
return Ok(Step {
|
|
||||||
cursor: filesystem_cursor.map(PostgresReconciliationCursor::Filesystem),
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 0,
|
|
||||||
candidates: recovered_candidates,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications,
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: recovery_retryable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let namespace = match phase {
|
|
||||||
Phase::Recovery => ReconciliationNamespace::Quarantine,
|
|
||||||
Phase::Sweep => ReconciliationNamespace::Final,
|
|
||||||
};
|
|
||||||
let store = Arc::clone(&self.store);
|
|
||||||
let (mut scan, candidates) = tokio::task::spawn_blocking(move || {
|
|
||||||
store.scan_reconciliation_namespace(
|
|
||||||
namespace,
|
|
||||||
filesystem_cursor,
|
|
||||||
limits.traversal_syscalls,
|
|
||||||
scan_candidate_limit,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
|
|
||||||
let mut step = Step {
|
|
||||||
cursor: scan
|
|
||||||
.continuation
|
|
||||||
.take()
|
|
||||||
.map(PostgresReconciliationCursor::Filesystem),
|
|
||||||
scan_complete: scan.stop == ReconciliationScanStop::Complete,
|
|
||||||
traversal_syscalls: scan.traversal_syscalls,
|
|
||||||
candidates: recovered_candidates,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: classifications
|
|
||||||
.checked_add(ReconciliationClassificationCounters {
|
|
||||||
scanner_malformed: scan.malformed,
|
|
||||||
scanner_unsafe: scan.unsafe_entries,
|
|
||||||
..ReconciliationClassificationCounters::default()
|
|
||||||
})
|
|
||||||
.ok_or(ReconciliationCoordinatorError::Bounds)?,
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: recovery_retryable || scan.stop == ReconciliationScanStop::Retryable,
|
|
||||||
};
|
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if step.candidates == limits.candidates || step.mutation_attempts == limits.mutations {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
step.candidates += 1;
|
|
||||||
let registration_grace = match phase {
|
|
||||||
Phase::Recovery => Duration::ZERO,
|
|
||||||
Phase::Sweep => RECONCILIATION_GRACE,
|
|
||||||
};
|
|
||||||
let store = Arc::clone(&self.store);
|
|
||||||
let registration = tokio::task::spawn_blocking(move || {
|
|
||||||
let registration = store.register_reconciliation(
|
|
||||||
&candidate,
|
|
||||||
registration_grace,
|
|
||||||
SystemTime::now(),
|
|
||||||
);
|
|
||||||
(candidate, registration)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
let (candidate, registration) = registration;
|
|
||||||
let artifact = match registration {
|
|
||||||
Ok(ReconciliationRegistration::Registered(artifact)) => artifact,
|
|
||||||
Ok(ReconciliationRegistration::NotEligible) => continue,
|
|
||||||
Err(error) => {
|
|
||||||
step.retryable |= step.classifications.record_registration_error(error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let claimed_at = self.database_now().await?;
|
|
||||||
let lease_expires_at = reconciliation_lease_expires_at(claimed_at);
|
|
||||||
let detached_grace = time::Duration::seconds(
|
|
||||||
i64::try_from(RECONCILIATION_GRACE.as_secs())
|
|
||||||
.expect("the fixed reconciliation grace fits i64"),
|
|
||||||
);
|
|
||||||
let claim = self
|
|
||||||
.registry
|
|
||||||
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
||||||
artifact: &artifact,
|
|
||||||
token: ArtifactClaimToken::generate(),
|
|
||||||
claimed_at,
|
|
||||||
lease_expires_at,
|
|
||||||
detached_grace,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
let claim = match claim {
|
|
||||||
ArtifactClaimOutcome::Claimed(claim) => claim,
|
|
||||||
ArtifactClaimOutcome::HeldByOther
|
|
||||||
| ArtifactClaimOutcome::ActiveReference
|
|
||||||
| ArtifactClaimOutcome::DetachedReferenceInGrace => continue,
|
|
||||||
ArtifactClaimOutcome::MetadataConflict => {
|
|
||||||
step.classifications.claim_metadata_conflict += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let rechecked_at = self.database_now().await?;
|
|
||||||
let recheck = self
|
|
||||||
.registry
|
|
||||||
.recheck_artifact_reconciliation_claim(&claim, rechecked_at, detached_grace)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?;
|
|
||||||
if recheck != ArtifactClaimRecheckOutcome::Mutate {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
step.mutation_attempts += 1;
|
|
||||||
step.physical_mutation_possible = true;
|
|
||||||
let store = Arc::clone(&self.store);
|
|
||||||
let mutation = match tokio::task::spawn_blocking(move || match phase {
|
|
||||||
Phase::Recovery => store.delete_quarantined_reconciliation(candidate),
|
|
||||||
Phase::Sweep => store.quarantine_reconciliation(candidate),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(mutation) => mutation,
|
|
||||||
Err(_) => Err(ArtifactError::Storage),
|
|
||||||
};
|
|
||||||
let mut observation = match mutation {
|
|
||||||
Ok(ReconciliationMutation::Quarantined)
|
|
||||||
| Ok(ReconciliationMutation::AlreadyQuarantined) => {
|
|
||||||
ArtifactClaimFinalization::Quarantined
|
|
||||||
}
|
|
||||||
Ok(ReconciliationMutation::Deleted) => ArtifactClaimFinalization::Deleted,
|
|
||||||
Ok(ReconciliationMutation::AlreadyAbsent) => {
|
|
||||||
ArtifactClaimFinalization::AlreadyAbsent
|
|
||||||
}
|
|
||||||
Ok(ReconciliationMutation::Retryable) => {
|
|
||||||
step.classifications.mutation_retryable += 1;
|
|
||||||
step.retryable = true;
|
|
||||||
ArtifactClaimFinalization::Retryable
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
step.retryable |= step.classifications.record_mutation_error(error);
|
|
||||||
ArtifactClaimFinalization::Retryable
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if matches!(
|
|
||||||
observation,
|
|
||||||
ArtifactClaimFinalization::Deleted | ArtifactClaimFinalization::AlreadyAbsent
|
|
||||||
) {
|
|
||||||
// `unavailable` means both canonical locations are confirmed
|
|
||||||
// absent. A concurrent republish can recreate final bytes
|
|
||||||
// without consulting PostgreSQL, so unlink alone is not proof.
|
|
||||||
let presence = self.presence(claim.artifact_ref().clone()).await?;
|
|
||||||
observation = match presence {
|
|
||||||
ReconciliationPresence::Absent => ArtifactClaimFinalization::AlreadyAbsent,
|
|
||||||
presence => {
|
|
||||||
step.retryable |= step.classifications.record_mutation_presence(presence);
|
|
||||||
ArtifactClaimFinalization::Retryable
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let observed_at = self.database_now().await?;
|
|
||||||
let finalization = match phase {
|
|
||||||
Phase::Recovery => {
|
|
||||||
self.registry
|
|
||||||
.recover_artifact_reconciliation_claim(&claim, observation, observed_at)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Phase::Sweep => {
|
|
||||||
self.registry
|
|
||||||
.finalize_artifact_reconciliation_claim(&claim, observation, observed_at)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let finalized_as_expected =
|
|
||||||
match observation {
|
|
||||||
ArtifactClaimFinalization::Deleted
|
|
||||||
| ArtifactClaimFinalization::AlreadyAbsent => matches!(
|
|
||||||
finalization.as_ref(),
|
|
||||||
Ok(ArtifactClaimFinalizeOutcome::Unavailable)
|
|
||||||
),
|
|
||||||
ArtifactClaimFinalization::Quarantined
|
|
||||||
| ArtifactClaimFinalization::Retryable => matches!(
|
|
||||||
finalization.as_ref(),
|
|
||||||
Ok(ArtifactClaimFinalizeOutcome::Retained)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
if !finalized_as_expected {
|
|
||||||
step.retryable = true;
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
finalization.as_ref(),
|
|
||||||
Ok(ArtifactClaimFinalizeOutcome::Unavailable)
|
|
||||||
) {
|
|
||||||
let presence = self.presence(claim.artifact_ref().clone()).await?;
|
|
||||||
step.retryable |= step.classifications.record_mutation_presence(presence);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The scan cursor and every remaining observation came from the
|
|
||||||
// pre-mutation namespace. Return immediately so the coordinator
|
|
||||||
// drops them and restarts a recovery cycle from `None`.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the protected root without running filesystem syscalls on Tokio's
|
|
||||||
/// async executor.
|
|
||||||
pub async fn open_reconciliation_store(
|
|
||||||
root: PathBuf,
|
|
||||||
) -> Result<Arc<ArtifactStore>, ReconciliationCoordinatorError> {
|
|
||||||
tokio::task::spawn_blocking(move || ArtifactStore::open(root).map(Arc::new))
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)?
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Backend)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the immediate bounded startup cycle, then schedules 15-minute ticks.
|
|
||||||
/// Recovery remains ahead of the final sweep even when it spans several ticks.
|
|
||||||
pub async fn spawn_artifact_reconciliation(registry: PostgresRegistry, store: Arc<ArtifactStore>) {
|
|
||||||
let cleanup_registry = registry.clone();
|
|
||||||
let backend = PostgresReconciliationBackend::new(registry, Arc::clone(&store));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend);
|
|
||||||
let mut temp_cursor = None;
|
|
||||||
let startup_maintenance = async {
|
|
||||||
observe_import_job_cleanup(&cleanup_registry).await;
|
|
||||||
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
|
|
||||||
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), None).await;
|
|
||||||
};
|
|
||||||
if tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, startup_maintenance)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
name: "admin.artifact_maintenance.startup_deadline",
|
|
||||||
"startup maintenance exceeded its shared deadline; remaining work will resume in background"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(RECONCILIATION_INTERVAL);
|
|
||||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
|
||||||
// The immediate tick was run above as part of startup composition.
|
|
||||||
interval.tick().await;
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
observe_import_job_cleanup(&cleanup_registry).await;
|
|
||||||
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
|
|
||||||
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), temp_cursor).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn observe_import_job_cleanup(registry: &PostgresRegistry) {
|
|
||||||
let cleanup = async {
|
|
||||||
let mut deleted_jobs = 0_u64;
|
|
||||||
let mut detached_sources = 0_u64;
|
|
||||||
loop {
|
|
||||||
let report = registry
|
|
||||||
.cleanup_expired_import_jobs(IMPORT_JOB_CLEANUP_LIMIT)
|
|
||||||
.await?;
|
|
||||||
deleted_jobs = deleted_jobs.saturating_add(report.deleted_jobs);
|
|
||||||
detached_sources = detached_sources.saturating_add(report.detached_sources);
|
|
||||||
if !report.more_work {
|
|
||||||
return Ok::<_, crank_registry::RegistryError>((deleted_jobs, detached_sources));
|
|
||||||
}
|
|
||||||
// Each pass commits independently. Yield before requesting the
|
|
||||||
// next lock batch so normal import traffic can make progress.
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
|
|
||||||
Ok(Ok((deleted_jobs, detached_sources))) => info!(
|
|
||||||
name: "admin.openapi_import_cleanup.tick",
|
|
||||||
deleted_jobs,
|
|
||||||
detached_sources,
|
|
||||||
more_work = false,
|
|
||||||
"OpenAPI import cleanup tick completed"
|
|
||||||
),
|
|
||||||
Ok(Err(_)) => warn!(
|
|
||||||
name: "admin.openapi_import_cleanup.failed",
|
|
||||||
error_category = "registry",
|
|
||||||
"OpenAPI import cleanup tick will be retried"
|
|
||||||
),
|
|
||||||
Err(_) => warn!(
|
|
||||||
name: "admin.openapi_import_cleanup.failed",
|
|
||||||
error_category = "deadline",
|
|
||||||
"OpenAPI import cleanup tick exceeded its deadline"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_bounded_reconciliation_tick<B: ReconciliationBackend>(
|
|
||||||
coordinator: &mut ReconciliationCoordinator<B>,
|
|
||||||
) -> Result<ReconciliationTickReport, ReconciliationCoordinatorError> {
|
|
||||||
tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, coordinator.tick())
|
|
||||||
.await
|
|
||||||
.map_err(|_| ReconciliationCoordinatorError::Deadline)?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn observe_stale_temp_cleanup(
|
|
||||||
store: Arc<ArtifactStore>,
|
|
||||||
cursor: Option<TempScanCursor>,
|
|
||||||
) -> Option<TempScanCursor> {
|
|
||||||
let progress = Arc::new(std::sync::Mutex::new(cursor));
|
|
||||||
let cleanup_progress = Arc::clone(&progress);
|
|
||||||
let cleanup = async move {
|
|
||||||
let mut scanned = 0_usize;
|
|
||||||
let mut omitted = 0_usize;
|
|
||||||
let mut deleted = 0_usize;
|
|
||||||
let mut retryable = 0_usize;
|
|
||||||
loop {
|
|
||||||
let scanner = Arc::clone(&store);
|
|
||||||
let cursor = cleanup_progress
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ArtifactError::Storage)?
|
|
||||||
.clone();
|
|
||||||
let (scan, candidates, next_cursor) = tokio::task::spawn_blocking(move || {
|
|
||||||
scanner.scan_stale_temps_after(
|
|
||||||
TEMP_CLEANUP_GRACE,
|
|
||||||
cursor,
|
|
||||||
TEMP_CLEANUP_SCAN_LIMIT,
|
|
||||||
TEMP_CLEANUP_DELETE_LIMIT,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| ArtifactError::Storage)??;
|
|
||||||
scanned = scanned.saturating_add(scan.scanned);
|
|
||||||
omitted = omitted.saturating_add(scan.omitted);
|
|
||||||
*cleanup_progress
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ArtifactError::Storage)? = Some(next_cursor);
|
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
let store = Arc::clone(&store);
|
|
||||||
match tokio::task::spawn_blocking(move || store.delete_stale_temp(candidate)).await
|
|
||||||
{
|
|
||||||
Ok(Ok(())) => deleted += 1,
|
|
||||||
Ok(Err(_)) | Err(_) => retryable += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scan.complete {
|
|
||||||
return Ok::<_, ArtifactError>((
|
|
||||||
scanned,
|
|
||||||
omitted,
|
|
||||||
deleted,
|
|
||||||
retryable,
|
|
||||||
cleanup_progress
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ArtifactError::Storage)?
|
|
||||||
.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
|
|
||||||
Ok(Ok((scanned, omitted, deleted, retryable, next_cursor))) => {
|
|
||||||
info!(
|
|
||||||
name: "admin.artifact_temp_cleanup.tick",
|
|
||||||
scanned,
|
|
||||||
omitted,
|
|
||||||
deleted,
|
|
||||||
retryable,
|
|
||||||
"artifact temporary-file cleanup tick completed"
|
|
||||||
);
|
|
||||||
next_cursor
|
|
||||||
}
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
warn!(
|
|
||||||
name: "admin.artifact_temp_cleanup.failed",
|
|
||||||
error_category = "backend",
|
|
||||||
"artifact temporary-file cleanup tick will be retried"
|
|
||||||
);
|
|
||||||
progress.lock().ok().and_then(|cursor| cursor.clone())
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
name: "admin.artifact_temp_cleanup.failed",
|
|
||||||
error_category = "deadline",
|
|
||||||
"artifact temporary-file cleanup tick exceeded its deadline"
|
|
||||||
);
|
|
||||||
progress.lock().ok().and_then(|cursor| cursor.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn observe_tick(result: Result<ReconciliationTickReport, ReconciliationCoordinatorError>) {
|
|
||||||
match result {
|
|
||||||
Ok(report) => info!(
|
|
||||||
name: "admin.artifact_reconciliation.tick",
|
|
||||||
traversal_syscalls = report.traversal_syscalls,
|
|
||||||
candidates = report.candidates,
|
|
||||||
mutation_attempts = report.mutation_attempts,
|
|
||||||
scanner_malformed = report.classifications.scanner_malformed,
|
|
||||||
scanner_unsafe = report.classifications.scanner_unsafe,
|
|
||||||
recovery_present = report.classifications.recovery_present,
|
|
||||||
recovery_safety = report.classifications.recovery_safety,
|
|
||||||
recovery_retryable = report.classifications.recovery_retryable,
|
|
||||||
registration_integrity = report.classifications.registration_integrity,
|
|
||||||
registration_safety = report.classifications.registration_safety,
|
|
||||||
registration_retryable = report.classifications.registration_retryable,
|
|
||||||
claim_metadata_conflict = report.classifications.claim_metadata_conflict,
|
|
||||||
mutation_integrity = report.classifications.mutation_integrity,
|
|
||||||
mutation_safety = report.classifications.mutation_safety,
|
|
||||||
mutation_retryable = report.classifications.mutation_retryable,
|
|
||||||
recovery_completed = report.recovery_completed,
|
|
||||||
cycle_completed = report.cycle_completed,
|
|
||||||
retryable = report.retryable,
|
|
||||||
"artifact reconciliation tick completed"
|
|
||||||
),
|
|
||||||
Err(error) => warn!(
|
|
||||||
name: "admin.artifact_reconciliation.failed",
|
|
||||||
error_category = match error {
|
|
||||||
ReconciliationCoordinatorError::Backend => "backend",
|
|
||||||
ReconciliationCoordinatorError::Bounds => "bounds",
|
|
||||||
ReconciliationCoordinatorError::Deadline => "deadline",
|
|
||||||
},
|
|
||||||
"artifact reconciliation tick will be retried"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "reconciliation/tests.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,690 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::VecDeque,
|
|
||||||
fs,
|
|
||||||
os::unix::fs::{PermissionsExt, symlink},
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::{
|
|
||||||
Arc, Mutex,
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crank_artifacts::{ReconciliationRegistration, RegisteredArtifact};
|
|
||||||
use crank_registry::MigrationAuthority;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
static NEXT_TEST_ROOT: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
|
|
||||||
struct TestRoot(PathBuf);
|
|
||||||
|
|
||||||
impl TestRoot {
|
|
||||||
fn new() -> Self {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"crank-admin-reconciliation-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT_TEST_ROOT.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestRoot {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn artifact_path(root: &Path, artifact: &RegisteredArtifact) -> PathBuf {
|
|
||||||
let digest = artifact.artifact_ref().digest_hex();
|
|
||||||
root.join("sha256").join(&digest[..2]).join(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn age_artifact(root: &Path, artifact: &RegisteredArtifact) {
|
|
||||||
fs::File::open(artifact_path(root, artifact))
|
|
||||||
.unwrap()
|
|
||||||
.set_modified(SystemTime::now() - RECONCILIATION_GRACE - Duration::from_secs(60))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_registered_artifact(store: &ArtifactStore, artifact: &RegisteredArtifact) {
|
|
||||||
let (_, candidates) = store
|
|
||||||
.scan_reconciliation_namespace(ReconciliationNamespace::Final, None, 4_096, 32)
|
|
||||||
.unwrap();
|
|
||||||
let candidate = candidates
|
|
||||||
.into_iter()
|
|
||||||
.find(|candidate| {
|
|
||||||
matches!(
|
|
||||||
store.register_reconciliation(candidate, Duration::ZERO, SystemTime::now()),
|
|
||||||
Ok(ReconciliationRegistration::Registered(found)) if found == *artifact
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.expect("the final artifact is discoverable");
|
|
||||||
assert!(matches!(
|
|
||||||
store.quarantine_reconciliation(candidate),
|
|
||||||
Ok(ReconciliationMutation::Quarantined | ReconciliationMutation::AlreadyQuarantined)
|
|
||||||
));
|
|
||||||
|
|
||||||
let (_, candidates) = store
|
|
||||||
.scan_reconciliation_namespace(ReconciliationNamespace::Quarantine, None, 4_096, 32)
|
|
||||||
.unwrap();
|
|
||||||
let candidate = candidates
|
|
||||||
.into_iter()
|
|
||||||
.find(|candidate| {
|
|
||||||
matches!(
|
|
||||||
store.register_reconciliation(candidate, Duration::ZERO, SystemTime::now()),
|
|
||||||
Ok(ReconciliationRegistration::Registered(found)) if found == *artifact
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.expect("the quarantined artifact is discoverable");
|
|
||||||
assert!(matches!(
|
|
||||||
store.delete_quarantined_reconciliation(candidate),
|
|
||||||
Ok(ReconciliationMutation::Deleted | ReconciliationMutation::AlreadyAbsent)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct StepTemplate {
|
|
||||||
scan_complete: bool,
|
|
||||||
traversal_syscalls: usize,
|
|
||||||
candidates: usize,
|
|
||||||
mutation_attempts: usize,
|
|
||||||
classifications: ReconciliationClassificationCounters,
|
|
||||||
physical_mutation_possible: bool,
|
|
||||||
retryable: bool,
|
|
||||||
return_cursor: bool,
|
|
||||||
error: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StepTemplate {
|
|
||||||
fn complete() -> Self {
|
|
||||||
Self {
|
|
||||||
scan_complete: true,
|
|
||||||
traversal_syscalls: 1,
|
|
||||||
candidates: 0,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: false,
|
|
||||||
return_cursor: false,
|
|
||||||
error: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CursorEvidence {
|
|
||||||
alive: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorEvidence {
|
|
||||||
fn new(alive: Arc<AtomicUsize>, maximum: &AtomicUsize) -> Self {
|
|
||||||
let current = alive.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
maximum.fetch_max(current, Ordering::SeqCst);
|
|
||||||
Self { alive }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for CursorEvidence {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.alive.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScriptedBackend {
|
|
||||||
steps: Mutex<VecDeque<StepTemplate>>,
|
|
||||||
calls: Mutex<Vec<(Phase, bool, StepLimits)>>,
|
|
||||||
alive: Arc<AtomicUsize>,
|
|
||||||
maximum: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScriptedBackend {
|
|
||||||
fn new(steps: impl IntoIterator<Item = StepTemplate>) -> Self {
|
|
||||||
Self {
|
|
||||||
steps: Mutex::new(steps.into_iter().collect()),
|
|
||||||
calls: Mutex::new(Vec::new()),
|
|
||||||
alive: Arc::new(AtomicUsize::new(0)),
|
|
||||||
maximum: AtomicUsize::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ReconciliationBackend for Arc<ScriptedBackend> {
|
|
||||||
type Cursor = CursorEvidence;
|
|
||||||
|
|
||||||
async fn step(
|
|
||||||
&self,
|
|
||||||
phase: Phase,
|
|
||||||
cursor: Option<Self::Cursor>,
|
|
||||||
limits: StepLimits,
|
|
||||||
) -> Result<Step<Self::Cursor>, ReconciliationCoordinatorError> {
|
|
||||||
self.calls
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push((phase, cursor.is_some(), limits));
|
|
||||||
drop(cursor);
|
|
||||||
let template = self.steps.lock().unwrap().pop_front().unwrap();
|
|
||||||
if template.error {
|
|
||||||
return Err(ReconciliationCoordinatorError::Backend);
|
|
||||||
}
|
|
||||||
let cursor = template
|
|
||||||
.return_cursor
|
|
||||||
.then(|| CursorEvidence::new(self.alive.clone(), &self.maximum));
|
|
||||||
Ok(Step {
|
|
||||||
cursor,
|
|
||||||
scan_complete: template.scan_complete,
|
|
||||||
traversal_syscalls: template.traversal_syscalls,
|
|
||||||
candidates: template.candidates,
|
|
||||||
mutation_attempts: template.mutation_attempts,
|
|
||||||
classifications: template.classifications,
|
|
||||||
physical_mutation_possible: template.physical_mutation_possible,
|
|
||||||
retryable: template.retryable,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recovery_always_completes_before_final_sweep() {
|
|
||||||
let backend = Arc::new(ScriptedBackend::new([
|
|
||||||
StepTemplate::complete(),
|
|
||||||
StepTemplate::complete(),
|
|
||||||
]));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
|
|
||||||
|
|
||||||
let report = coordinator.tick().await.unwrap();
|
|
||||||
|
|
||||||
assert!(report.recovery_completed);
|
|
||||||
assert!(report.cycle_completed);
|
|
||||||
let phases = backend
|
|
||||||
.calls
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.map(|call| call.0)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(phases, vec![Phase::Recovery, Phase::Sweep]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn possible_mutation_discards_cursor_and_restarts_recovery() {
|
|
||||||
let paged = StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 2,
|
|
||||||
candidates: 1,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: true,
|
|
||||||
return_cursor: true,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
let mutation = StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 1,
|
|
||||||
candidates: 1,
|
|
||||||
mutation_attempts: 1,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: true,
|
|
||||||
retryable: false,
|
|
||||||
return_cursor: true,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
let backend = Arc::new(ScriptedBackend::new([
|
|
||||||
StepTemplate::complete(),
|
|
||||||
paged,
|
|
||||||
mutation,
|
|
||||||
StepTemplate::complete(),
|
|
||||||
StepTemplate::complete(),
|
|
||||||
]));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
|
|
||||||
|
|
||||||
let first = coordinator.tick().await.unwrap();
|
|
||||||
assert!(first.retryable);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
|
|
||||||
|
|
||||||
let second = coordinator.tick().await.unwrap();
|
|
||||||
assert!(second.cycle_completed);
|
|
||||||
let calls = backend.calls.lock().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
calls
|
|
||||||
.iter()
|
|
||||||
.map(|(phase, had_cursor, _)| (*phase, *had_cursor))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
(Phase::Recovery, false),
|
|
||||||
(Phase::Sweep, false),
|
|
||||||
(Phase::Sweep, true),
|
|
||||||
(Phase::Recovery, false),
|
|
||||||
(Phase::Sweep, false),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
|
|
||||||
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn backend_error_discards_cursor_and_restarts_recovery() {
|
|
||||||
let paged = StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 1,
|
|
||||||
candidates: 1,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: true,
|
|
||||||
return_cursor: true,
|
|
||||||
error: false,
|
|
||||||
};
|
|
||||||
let failure = StepTemplate {
|
|
||||||
error: true,
|
|
||||||
..StepTemplate::complete()
|
|
||||||
};
|
|
||||||
let backend = Arc::new(ScriptedBackend::new([
|
|
||||||
StepTemplate::complete(),
|
|
||||||
paged,
|
|
||||||
failure,
|
|
||||||
StepTemplate::complete(),
|
|
||||||
StepTemplate::complete(),
|
|
||||||
]));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
|
|
||||||
|
|
||||||
assert!(coordinator.tick().await.unwrap().retryable);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
|
|
||||||
assert_eq!(
|
|
||||||
coordinator.tick().await,
|
|
||||||
Err(ReconciliationCoordinatorError::Backend)
|
|
||||||
);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
|
|
||||||
assert!(coordinator.tick().await.unwrap().cycle_completed);
|
|
||||||
|
|
||||||
let calls = backend.calls.lock().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
calls
|
|
||||||
.iter()
|
|
||||||
.map(|(phase, had_cursor, _)| (*phase, *had_cursor))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
(Phase::Recovery, false),
|
|
||||||
(Phase::Sweep, false),
|
|
||||||
(Phase::Sweep, true),
|
|
||||||
(Phase::Recovery, false),
|
|
||||||
(Phase::Sweep, false),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn backend_cannot_exceed_tick_limits() {
|
|
||||||
let backend = Arc::new(ScriptedBackend::new([StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT + 1,
|
|
||||||
candidates: 0,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: false,
|
|
||||||
return_cursor: false,
|
|
||||||
error: false,
|
|
||||||
}]));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
coordinator.tick().await,
|
|
||||||
Err(ReconciliationCoordinatorError::Bounds)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn tick_stops_at_the_exact_candidate_and_mutation_limits() {
|
|
||||||
let backend = Arc::new(ScriptedBackend::new(
|
|
||||||
(0..RECONCILIATION_MUTATION_LIMIT).map(|_| StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 1,
|
|
||||||
candidates: 1,
|
|
||||||
mutation_attempts: 1,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: true,
|
|
||||||
retryable: false,
|
|
||||||
return_cursor: true,
|
|
||||||
error: false,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
|
|
||||||
|
|
||||||
let report = coordinator.tick().await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.candidates, RECONCILIATION_CANDIDATE_LIMIT);
|
|
||||||
assert_eq!(report.mutation_attempts, RECONCILIATION_MUTATION_LIMIT);
|
|
||||||
assert_eq!(report.traversal_syscalls, RECONCILIATION_MUTATION_LIMIT);
|
|
||||||
assert_eq!(
|
|
||||||
backend.calls.lock().unwrap().len(),
|
|
||||||
RECONCILIATION_MUTATION_LIMIT
|
|
||||||
);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
|
|
||||||
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn coordinator_retains_at_most_one_cursor_between_ticks() {
|
|
||||||
let backend = Arc::new(ScriptedBackend::new((0..128).map(|_| StepTemplate {
|
|
||||||
scan_complete: false,
|
|
||||||
traversal_syscalls: 1,
|
|
||||||
candidates: 0,
|
|
||||||
mutation_attempts: 0,
|
|
||||||
classifications: ReconciliationClassificationCounters::default(),
|
|
||||||
physical_mutation_possible: false,
|
|
||||||
retryable: true,
|
|
||||||
return_cursor: true,
|
|
||||||
error: false,
|
|
||||||
})));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend.clone());
|
|
||||||
|
|
||||||
for _ in 0..128 {
|
|
||||||
coordinator.tick().await.unwrap();
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 1);
|
|
||||||
}
|
|
||||||
assert_eq!(backend.maximum.load(Ordering::SeqCst), 1);
|
|
||||||
drop(coordinator);
|
|
||||||
assert_eq!(backend.alive.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registration_failures_have_a_closed_redacted_classification() {
|
|
||||||
let mut counters = ReconciliationClassificationCounters::default();
|
|
||||||
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::Integrity));
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::UnsafeRoot));
|
|
||||||
assert!(counters.record_registration_error(ArtifactError::Storage));
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::NotFound));
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::InvalidReference));
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::EmptySource));
|
|
||||||
assert!(!counters.record_registration_error(ArtifactError::SourceTooLarge));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
counters,
|
|
||||||
ReconciliationClassificationCounters {
|
|
||||||
scanner_malformed: 0,
|
|
||||||
scanner_unsafe: 0,
|
|
||||||
recovery_present: 0,
|
|
||||||
recovery_safety: 0,
|
|
||||||
recovery_retryable: 0,
|
|
||||||
registration_integrity: 3,
|
|
||||||
registration_safety: 3,
|
|
||||||
registration_retryable: 1,
|
|
||||||
claim_metadata_conflict: 0,
|
|
||||||
mutation_integrity: 0,
|
|
||||||
mutation_safety: 0,
|
|
||||||
mutation_retryable: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let debug = format!("{counters:?}");
|
|
||||||
assert!(!debug.contains("sha256:"));
|
|
||||||
assert!(!debug.contains('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mutation_failures_are_classified_and_always_retryable() {
|
|
||||||
let mut counters = ReconciliationClassificationCounters::default();
|
|
||||||
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::Integrity));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::UnsafeRoot));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::Storage));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::NotFound));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::InvalidReference));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::EmptySource));
|
|
||||||
assert!(counters.record_mutation_error(ArtifactError::SourceTooLarge));
|
|
||||||
|
|
||||||
assert_eq!(counters.mutation_integrity, 3);
|
|
||||||
assert_eq!(counters.mutation_safety, 3);
|
|
||||||
assert_eq!(counters.mutation_retryable, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn production_backend_recovers_absence_before_sweep_and_restarts_after_mutation() {
|
|
||||||
let database_url =
|
|
||||||
crank_test_support::postgres_schema_url("admin_reconciliation_backend").await;
|
|
||||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
|
||||||
MigrationAuthority::apply(&pool).await.unwrap();
|
|
||||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
|
||||||
let root = TestRoot::new();
|
|
||||||
let store = ArtifactStore::open(&root.0).unwrap();
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
|
|
||||||
let expired = store.put_registered(b"expired physical absence\n").unwrap();
|
|
||||||
let expired_claim = registry
|
|
||||||
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
||||||
artifact: &expired,
|
|
||||||
token: ArtifactClaimToken::generate(),
|
|
||||||
claimed_at: now - time::Duration::minutes(10),
|
|
||||||
lease_expires_at: now - time::Duration::minutes(5),
|
|
||||||
detached_grace: time::Duration::hours(24),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(expired_claim, ArtifactClaimOutcome::Claimed(_)));
|
|
||||||
remove_registered_artifact(&store, &expired);
|
|
||||||
sqlx::query(
|
|
||||||
"update artifact_blobs
|
|
||||||
set claim_expires_at = clock_timestamp() - interval '1 second'
|
|
||||||
where digest = $1",
|
|
||||||
)
|
|
||||||
.bind(expired.artifact_ref().digest_hex())
|
|
||||||
.execute(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let corrupted = store
|
|
||||||
.put_registered(b"integrity-blocked-candidate\n")
|
|
||||||
.unwrap();
|
|
||||||
let corrupted_path = artifact_path(&root.0, &corrupted);
|
|
||||||
let corrupted_size = fs::metadata(&corrupted_path).unwrap().len() as usize;
|
|
||||||
fs::set_permissions(&corrupted_path, fs::Permissions::from_mode(0o600)).unwrap();
|
|
||||||
fs::write(&corrupted_path, vec![b'x'; corrupted_size]).unwrap();
|
|
||||||
fs::set_permissions(&corrupted_path, fs::Permissions::from_mode(0o400)).unwrap();
|
|
||||||
age_artifact(&root.0, &corrupted);
|
|
||||||
|
|
||||||
let later = store.put_registered(b"later-valid-candidate\n").unwrap();
|
|
||||||
age_artifact(&root.0, &later);
|
|
||||||
let metadata_conflict = store
|
|
||||||
.put_registered(b"metadata-conflict-candidate\n")
|
|
||||||
.unwrap();
|
|
||||||
age_artifact(&root.0, &metadata_conflict);
|
|
||||||
sqlx::query(
|
|
||||||
"insert into artifact_blobs
|
|
||||||
(digest, artifact_ref, size_bytes, storage_lifecycle, created_at, updated_at)
|
|
||||||
values ($1, $2, $3, 'available', $4, $4)",
|
|
||||||
)
|
|
||||||
.bind(metadata_conflict.artifact_ref().digest_hex())
|
|
||||||
.bind(metadata_conflict.artifact_ref().as_str())
|
|
||||||
.bind(i64::try_from(metadata_conflict.size_bytes()).unwrap() + 1)
|
|
||||||
.bind(now)
|
|
||||||
.execute(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let shard = artifact_path(&root.0, &later)
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.to_path_buf();
|
|
||||||
fs::write(shard.join("not-a-digest"), b"malformed").unwrap();
|
|
||||||
let unsafe_name = format!(
|
|
||||||
"{}{}",
|
|
||||||
&later.artifact_ref().digest_hex()[..2],
|
|
||||||
"f".repeat(62)
|
|
||||||
);
|
|
||||||
assert_ne!(unsafe_name, later.artifact_ref().digest_hex());
|
|
||||||
symlink("not-a-digest", shard.join(unsafe_name)).unwrap();
|
|
||||||
|
|
||||||
let backend = PostgresReconciliationBackend::new(registry.clone(), Arc::new(store.clone()));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::with_tick_limits(
|
|
||||||
backend,
|
|
||||||
StepLimits {
|
|
||||||
traversal_syscalls: RECONCILIATION_TRAVERSAL_LIMIT,
|
|
||||||
candidates: RECONCILIATION_CANDIDATE_LIMIT,
|
|
||||||
mutations: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The single mutation slot is consumed by expired-claim recovery. The old
|
|
||||||
// final candidate proves Sweep did not run ahead of Recovery.
|
|
||||||
let first = coordinator.tick().await.unwrap();
|
|
||||||
assert_eq!(first.mutation_attempts, 1);
|
|
||||||
assert!(!first.recovery_completed);
|
|
||||||
assert_eq!(
|
|
||||||
store.reconciliation_presence(later.artifact_ref()).unwrap(),
|
|
||||||
ReconciliationPresence::Final
|
|
||||||
);
|
|
||||||
let lifecycle: String =
|
|
||||||
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
|
|
||||||
.bind(expired.artifact_ref().digest_hex())
|
|
||||||
.fetch_one(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(lifecycle, "unavailable");
|
|
||||||
|
|
||||||
// The real final sweep classifies blocked entries and still reaches the
|
|
||||||
// later valid item. Its physical mutation invalidates the final cursor.
|
|
||||||
let second = coordinator.tick().await.unwrap();
|
|
||||||
assert!(second.recovery_completed);
|
|
||||||
assert_eq!(second.mutation_attempts, 1);
|
|
||||||
assert_eq!(
|
|
||||||
store.reconciliation_presence(later.artifact_ref()).unwrap(),
|
|
||||||
ReconciliationPresence::Quarantine
|
|
||||||
);
|
|
||||||
|
|
||||||
// Model the next scheduled/restarted lease window. Because the coordinator
|
|
||||||
// reset to Recovery and discarded its final cursor, quarantine is handled
|
|
||||||
// before another final sweep.
|
|
||||||
sqlx::query("update artifact_blobs set claim_expires_at = $1 where digest = $2")
|
|
||||||
.bind(OffsetDateTime::now_utc() - time::Duration::seconds(1))
|
|
||||||
.bind(later.artifact_ref().digest_hex())
|
|
||||||
.execute(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let third = coordinator.tick().await.unwrap();
|
|
||||||
assert_eq!(third.mutation_attempts, 1);
|
|
||||||
assert_eq!(
|
|
||||||
store.reconciliation_presence(later.artifact_ref()).unwrap(),
|
|
||||||
ReconciliationPresence::Absent
|
|
||||||
);
|
|
||||||
|
|
||||||
let fourth = coordinator.tick().await.unwrap();
|
|
||||||
assert!(fourth.cycle_completed);
|
|
||||||
let classifications = first
|
|
||||||
.classifications
|
|
||||||
.checked_add(second.classifications)
|
|
||||||
.and_then(|value| value.checked_add(third.classifications))
|
|
||||||
.and_then(|value| value.checked_add(fourth.classifications))
|
|
||||||
.unwrap();
|
|
||||||
assert!(classifications.scanner_malformed > 0);
|
|
||||||
assert!(classifications.scanner_unsafe > 0);
|
|
||||||
assert!(classifications.registration_integrity > 0);
|
|
||||||
assert!(classifications.claim_metadata_conflict > 0);
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.reconciliation_presence(corrupted.artifact_ref())
|
|
||||||
.unwrap(),
|
|
||||||
ReconciliationPresence::Unsafe
|
|
||||||
);
|
|
||||||
assert_eq!(classifications.registration_safety, 0);
|
|
||||||
assert_eq!(classifications.registration_retryable, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn expired_claim_cursor_prevents_unsafe_prefix_starvation_across_ticks() {
|
|
||||||
let database_url =
|
|
||||||
crank_test_support::postgres_schema_url("admin_reconciliation_starvation").await;
|
|
||||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
|
||||||
MigrationAuthority::apply(&pool).await.unwrap();
|
|
||||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
|
||||||
let root = TestRoot::new();
|
|
||||||
let store = ArtifactStore::open(&root.0).unwrap();
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
|
|
||||||
for index in 0..=RECONCILIATION_CANDIDATE_LIMIT {
|
|
||||||
let artifact = store
|
|
||||||
.put_registered(format!("unsafe expired claim {index}\n").as_bytes())
|
|
||||||
.unwrap();
|
|
||||||
let outcome = registry
|
|
||||||
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
||||||
artifact: &artifact,
|
|
||||||
token: ArtifactClaimToken::generate(),
|
|
||||||
claimed_at: now - time::Duration::minutes(20),
|
|
||||||
lease_expires_at: now - time::Duration::minutes(15),
|
|
||||||
detached_grace: time::Duration::hours(24),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(outcome, ArtifactClaimOutcome::Claimed(_)));
|
|
||||||
sqlx::query(
|
|
||||||
"update artifact_blobs
|
|
||||||
set claim_expires_at = clock_timestamp() - interval '10 minutes'
|
|
||||||
where digest = $1",
|
|
||||||
)
|
|
||||||
.bind(artifact.artifact_ref().digest_hex())
|
|
||||||
.execute(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
fs::set_permissions(
|
|
||||||
artifact_path(&root.0, &artifact),
|
|
||||||
fs::Permissions::from_mode(0o600),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let absent = store
|
|
||||||
.put_registered(b"absent after unsafe prefix\n")
|
|
||||||
.unwrap();
|
|
||||||
let outcome = registry
|
|
||||||
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
||||||
artifact: &absent,
|
|
||||||
token: ArtifactClaimToken::generate(),
|
|
||||||
claimed_at: now - time::Duration::minutes(15),
|
|
||||||
lease_expires_at: now - time::Duration::minutes(10),
|
|
||||||
detached_grace: time::Duration::hours(24),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(outcome, ArtifactClaimOutcome::Claimed(_)));
|
|
||||||
sqlx::query(
|
|
||||||
"update artifact_blobs
|
|
||||||
set claim_expires_at = clock_timestamp() - interval '5 minutes'
|
|
||||||
where digest = $1",
|
|
||||||
)
|
|
||||||
.bind(absent.artifact_ref().digest_hex())
|
|
||||||
.execute(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
remove_registered_artifact(&store, &absent);
|
|
||||||
|
|
||||||
let backend = PostgresReconciliationBackend::new(registry.clone(), Arc::new(store));
|
|
||||||
let mut coordinator = ReconciliationCoordinator::new(backend);
|
|
||||||
let first = coordinator.tick().await.unwrap();
|
|
||||||
assert_eq!(first.candidates, RECONCILIATION_CANDIDATE_LIMIT);
|
|
||||||
assert_eq!(first.mutation_attempts, 0);
|
|
||||||
assert_eq!(
|
|
||||||
first.classifications.recovery_safety,
|
|
||||||
RECONCILIATION_CANDIDATE_LIMIT
|
|
||||||
);
|
|
||||||
let first_lifecycle: String =
|
|
||||||
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
|
|
||||||
.bind(absent.artifact_ref().digest_hex())
|
|
||||||
.fetch_one(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(first_lifecycle, "available");
|
|
||||||
|
|
||||||
let second = coordinator.tick().await.unwrap();
|
|
||||||
assert!(second.classifications.recovery_safety > 0);
|
|
||||||
assert!(second.mutation_attempts > 0);
|
|
||||||
let second_lifecycle: String =
|
|
||||||
sqlx::query_scalar("select storage_lifecycle from artifact_blobs where digest = $1")
|
|
||||||
.bind(absent.artifact_ref().digest_hex())
|
|
||||||
.fetch_one(registry.pool())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(second_lifecycle, "unavailable");
|
|
||||||
}
|
|
||||||
@@ -1,144 +1,165 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{MatchedPath, Request},
|
extract::Request,
|
||||||
http::{HeaderName, HeaderValue},
|
http::{HeaderMap, HeaderName, HeaderValue},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use crank_core::{CorrelationContext, RequestId, TraceContext};
|
use tracing::info;
|
||||||
use crank_metrics::ExemplarTraceId;
|
use uuid::Uuid;
|
||||||
use crank_observability::with_request_correlation;
|
|
||||||
use tracing::{Instrument, info, info_span};
|
|
||||||
|
|
||||||
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||||
pub const TRACE_ID_HEADER: HeaderName = HeaderName::from_static("x-trace-id");
|
const MAX_REQUEST_ID_LEN: usize = 128;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RequestContext {
|
pub struct RequestContext {
|
||||||
pub correlation: CorrelationContext,
|
pub request_id: String,
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestContext {
|
|
||||||
pub fn request_id(&self) -> &str {
|
|
||||||
self.correlation.request_id().as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trace_id(&self) -> &str {
|
|
||||||
self.correlation.trace_id().as_str()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
||||||
let (request_id, remote_parent) = resolve_correlation(request.headers());
|
|
||||||
let method = request.method().clone();
|
|
||||||
let route = request
|
|
||||||
.extensions()
|
|
||||||
.get::<MatchedPath>()
|
|
||||||
.map_or("unmatched", MatchedPath::as_str)
|
|
||||||
.to_owned();
|
|
||||||
let span = info_span!(
|
|
||||||
target: "crank::trace",
|
|
||||||
"http.request",
|
|
||||||
request_id = %request_id,
|
|
||||||
trace_id = tracing::field::Empty,
|
|
||||||
);
|
|
||||||
if let Some(remote_parent) = remote_parent.as_ref() {
|
|
||||||
set_canonical_parent(&span, remote_parent);
|
|
||||||
}
|
|
||||||
let trace_context = crank_trace::trace_context_for_span(&span).unwrap_or_else(|| {
|
|
||||||
remote_parent
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(TraceContext::generate, TraceContext::continue_local)
|
|
||||||
});
|
|
||||||
span.record("trace_id", trace_context.trace_id().as_str());
|
|
||||||
let context = RequestContext {
|
let context = RequestContext {
|
||||||
correlation: CorrelationContext::new(request_id, trace_context),
|
request_id: resolve_request_id(request.headers()),
|
||||||
};
|
};
|
||||||
|
let method = request.method().clone();
|
||||||
|
let path = request.uri().path().to_owned();
|
||||||
request.extensions_mut().insert(context.clone());
|
request.extensions_mut().insert(context.clone());
|
||||||
|
|
||||||
with_request_correlation(
|
let mut response = next.run(request).await;
|
||||||
context.correlation.request_id().to_string(),
|
info!(
|
||||||
context.correlation.trace_id().to_string(),
|
request_id = %context.request_id,
|
||||||
async move {
|
method = %method,
|
||||||
let mut response = next.run(request).instrument(span).await;
|
path,
|
||||||
info!(
|
status = response.status().as_u16(),
|
||||||
name: "admin.request.completed",
|
"admin request completed"
|
||||||
request_id = %context.correlation.request_id(),
|
|
||||||
trace_id = %context.correlation.trace_id(),
|
|
||||||
method = %method,
|
|
||||||
route,
|
|
||||||
status = response.status().as_u16(),
|
|
||||||
"admin request completed"
|
|
||||||
);
|
|
||||||
if let Ok(value) = HeaderValue::from_str(context.correlation.request_id().as_str()) {
|
|
||||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
|
||||||
}
|
|
||||||
if let Ok(value) = HeaderValue::from_str(context.correlation.trace_id().as_str()) {
|
|
||||||
response.headers_mut().insert(TRACE_ID_HEADER, value);
|
|
||||||
}
|
|
||||||
if context.correlation.trace_context().is_sampled()
|
|
||||||
&& let Some(exemplar) =
|
|
||||||
ExemplarTraceId::parse(context.correlation.trace_id().as_str())
|
|
||||||
{
|
|
||||||
response.extensions_mut().insert(exemplar);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_correlation(headers: &axum::http::HeaderMap) -> (RequestId, Option<TraceContext>) {
|
|
||||||
let _tracestate_accepted = one_auxiliary_header_within_budget(
|
|
||||||
headers,
|
|
||||||
"tracestate",
|
|
||||||
TraceContext::tracestate_within_budget,
|
|
||||||
);
|
);
|
||||||
let _baggage_accepted =
|
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||||
one_auxiliary_header_within_budget(headers, "baggage", TraceContext::baggage_within_budget);
|
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||||
let mut request_ids = headers.get_all(REQUEST_ID_HEADER).iter();
|
}
|
||||||
let request_id = request_ids.next().and_then(|value| value.to_str().ok());
|
response
|
||||||
let request_id = if request_ids.next().is_some() {
|
|
||||||
RequestId::generate()
|
|
||||||
} else {
|
|
||||||
RequestId::resolve(request_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut traceparents = headers.get_all("traceparent").iter();
|
|
||||||
let traceparent = traceparents.next().and_then(|value| value.to_str().ok());
|
|
||||||
let remote_parent = if traceparents.next().is_some() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
traceparent.and_then(|value| TraceContext::parse(value).ok())
|
|
||||||
};
|
|
||||||
(request_id, remote_parent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one_auxiliary_header_within_budget(
|
fn resolve_request_id(headers: &HeaderMap) -> String {
|
||||||
headers: &axum::http::HeaderMap,
|
headers
|
||||||
name: &'static str,
|
.get(&REQUEST_ID_HEADER)
|
||||||
validate: fn(&str) -> bool,
|
.and_then(|value| value.to_str().ok())
|
||||||
) -> bool {
|
.map(str::trim)
|
||||||
let mut values = headers.get_all(name).iter();
|
.filter(|value| is_valid_request_id(value))
|
||||||
let value = values.next().and_then(|value| value.to_str().ok());
|
.map(ToOwned::to_owned)
|
||||||
values.next().is_none() && value.is_some_and(validate)
|
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_canonical_parent(span: &tracing::Span, context: &TraceContext) {
|
fn is_valid_request_id(value: &str) -> bool {
|
||||||
crank_trace::set_parent_from_trace_context(span, context);
|
!value.is_empty()
|
||||||
|
&& value.len() <= MAX_REQUEST_ID_LEN
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::io;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||||
|
|
||||||
|
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_visible_ascii_request_ids() {
|
fn accepts_visible_ascii_request_ids() {
|
||||||
assert!(crank_core::RequestId::is_valid("req_test_123"));
|
assert!(is_valid_request_id("req_test_123"));
|
||||||
assert!(crank_core::RequestId::is_valid("trace-123/abc"));
|
assert!(is_valid_request_id("trace-123/abc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_empty_or_control_request_ids() {
|
fn rejects_empty_or_control_request_ids() {
|
||||||
assert!(!crank_core::RequestId::is_valid(""));
|
assert!(!is_valid_request_id(""));
|
||||||
assert!(!crank_core::RequestId::is_valid("bad value"));
|
assert!(!is_valid_request_id("bad value"));
|
||||||
assert!(!crank_core::RequestId::is_valid("bad\nvalue"));
|
assert!(!is_valid_request_id("bad\nvalue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct SharedLogWriter {
|
||||||
|
buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedLogWriter {
|
||||||
|
fn output(&self) -> String {
|
||||||
|
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||||
|
type Writer = SharedLogGuard;
|
||||||
|
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
SharedLogGuard {
|
||||||
|
buffer: Arc::clone(&self.buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharedLogGuard {
|
||||||
|
buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl io::Write for SharedLogGuard {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logs_request_completion_with_request_id() {
|
||||||
|
let writer = SharedLogWriter::default();
|
||||||
|
let subscriber = tracing_subscriber::registry().with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(writer.clone())
|
||||||
|
.without_time()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.with_filter(LevelFilter::INFO),
|
||||||
|
);
|
||||||
|
let dispatch = tracing::Dispatch::new(subscriber);
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/probe", get(|| async { "ok" }))
|
||||||
|
.layer(axum::middleware::from_fn(apply_request_context));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = Client::new()
|
||||||
|
.get(format!("http://{address}/probe"))
|
||||||
|
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"req_admin_trace_123"
|
||||||
|
);
|
||||||
|
|
||||||
|
let logs = writer.output();
|
||||||
|
assert!(logs.contains("admin request completed"));
|
||||||
|
assert!(logs.contains("req_admin_trace_123"));
|
||||||
|
assert!(logs.contains("GET"));
|
||||||
|
assert!(logs.contains("/probe"));
|
||||||
|
assert!(logs.contains("status=200"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,110 +3,19 @@ pub mod agents;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_profiles;
|
pub mod auth_profiles;
|
||||||
pub mod capabilities;
|
pub mod capabilities;
|
||||||
pub mod imports;
|
pub mod machine_auth;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod onboarding;
|
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod upstreams;
|
pub mod streaming;
|
||||||
pub mod workspaces;
|
pub mod workspaces;
|
||||||
|
|
||||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
use axum::Json;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
pub async fn health() -> Json<serde_json::Value> {
|
pub async fn health() -> Json<serde_json::Value> {
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"service": "admin-api",
|
"service": "admin-api",
|
||||||
"status": "ok"
|
"status": "ok"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
|
||||||
let checks = state.service.readiness().await;
|
|
||||||
let postgres = if checks.postgres {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
};
|
|
||||||
let artifact_storage = if checks.artifact_storage {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
};
|
|
||||||
if checks.is_ready() {
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(json!({
|
|
||||||
"service": "admin-api",
|
|
||||||
"status": "ready",
|
|
||||||
"checks": { "postgres": postgres, "artifact_storage": artifact_storage }
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
Json(json!({
|
|
||||||
"service": "admin-api",
|
|
||||||
"status": "not_ready",
|
|
||||||
"checks": { "postgres": postgres, "artifact_storage": artifact_storage }
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::service::ReadinessChecks;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn readiness_checks_identify_a_postgres_failure() {
|
|
||||||
let checks = ReadinessChecks {
|
|
||||||
postgres: false,
|
|
||||||
artifact_storage: true,
|
|
||||||
};
|
|
||||||
assert!(!checks.is_ready());
|
|
||||||
assert_eq!(
|
|
||||||
if checks.postgres {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
},
|
|
||||||
"not_ready"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
if checks.artifact_storage {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
},
|
|
||||||
"ready"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn readiness_checks_identify_an_artifact_storage_failure() {
|
|
||||||
let checks = ReadinessChecks {
|
|
||||||
postgres: true,
|
|
||||||
artifact_storage: false,
|
|
||||||
};
|
|
||||||
assert!(!checks.is_ready());
|
|
||||||
assert_eq!(
|
|
||||||
if checks.postgres {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
},
|
|
||||||
"ready"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
if checks.artifact_storage {
|
|
||||||
"ready"
|
|
||||||
} else {
|
|
||||||
"not_ready"
|
|
||||||
},
|
|
||||||
"not_ready"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,287 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Extension, Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use crank_core::{
|
||||||
|
AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction,
|
||||||
|
PolicyDecision, PolicyScope, SessionActor,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{
|
||||||
|
auth::AuthenticatedSession,
|
||||||
|
error::ApiError,
|
||||||
|
service::{InvitationPayload, UpdateMembershipPayload},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct WorkspacePath {
|
pub struct WorkspacePath {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WorkspaceInvitationPath {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub invitation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WorkspaceMembershipPath {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_memberships(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let items = state
|
||||||
|
.service
|
||||||
|
.list_memberships(&path.workspace_id.as_str().into())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_membership(
|
||||||
|
Path(path): Path<WorkspaceMembershipPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
Json(payload): Json<UpdateMembershipPayload>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||||
|
enforce_workspace_policy(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
PolicyAction::WriteWorkspaceAccess,
|
||||||
|
)?;
|
||||||
|
let items = state
|
||||||
|
.service
|
||||||
|
.update_membership_role(
|
||||||
|
&workspace_id,
|
||||||
|
&session.user.id,
|
||||||
|
&path.user_id.as_str().into(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
record_access_audit(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
"membership.role_updated",
|
||||||
|
AuditTargetKind::Membership,
|
||||||
|
path.user_id.clone(),
|
||||||
|
json!({ "user_id": path.user_id }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_membership(
|
||||||
|
Path(path): Path<WorkspaceMembershipPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||||
|
enforce_workspace_policy(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
PolicyAction::WriteWorkspaceAccess,
|
||||||
|
)?;
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.remove_membership(
|
||||||
|
&workspace_id,
|
||||||
|
&session.user.id,
|
||||||
|
&path.user_id.as_str().into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
record_access_audit(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
"membership.removed",
|
||||||
|
AuditTargetKind::Membership,
|
||||||
|
path.user_id.clone(),
|
||||||
|
json!({ "user_id": path.user_id }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_invitations(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let items = state
|
||||||
|
.service
|
||||||
|
.list_invitations(&path.workspace_id.as_str().into())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invitation(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
Json(payload): Json<InvitationPayload>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||||
|
enforce_workspace_policy(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
PolicyAction::WriteWorkspaceAccess,
|
||||||
|
)?;
|
||||||
|
let created = state
|
||||||
|
.service
|
||||||
|
.create_invitation(&workspace_id, payload)
|
||||||
|
.await?;
|
||||||
|
record_access_audit(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
"invitation.created",
|
||||||
|
AuditTargetKind::Invitation,
|
||||||
|
created.invitation.invitation.id.as_str().to_owned(),
|
||||||
|
json!({ "invitation_id": created.invitation.invitation.id.as_str() }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_invitation(
|
||||||
|
Path(path): Path<WorkspaceInvitationPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||||
|
enforce_workspace_policy(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
PolicyAction::WriteWorkspaceAccess,
|
||||||
|
)?;
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.delete_invitation(&workspace_id, &path.invitation_id.as_str().into())
|
||||||
|
.await?;
|
||||||
|
record_access_audit(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
"invitation.deleted",
|
||||||
|
AuditTargetKind::Invitation,
|
||||||
|
path.invitation_id.clone(),
|
||||||
|
json!({ "invitation_id": path.invitation_id }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn export_workspace(
|
pub async fn export_workspace(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let exported = state
|
let exported = state
|
||||||
.service
|
.service
|
||||||
.export_workspace_catalog_snapshot(&path.workspace_id.as_str().into())
|
.export_workspace(&path.workspace_id.as_str().into())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(exported)))
|
Ok(Json(json!(exported)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_workspace(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||||
|
enforce_workspace_policy(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
PolicyAction::WriteWorkspace,
|
||||||
|
)?;
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.delete_workspace(&workspace_id, &session.user.id)
|
||||||
|
.await?;
|
||||||
|
record_access_audit(
|
||||||
|
&state,
|
||||||
|
&session,
|
||||||
|
&workspace_id,
|
||||||
|
"workspace.deleted",
|
||||||
|
AuditTargetKind::Workspace,
|
||||||
|
workspace_id.as_str().to_owned(),
|
||||||
|
json!({ "workspace_id": workspace_id.as_str() }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce_workspace_policy(
|
||||||
|
state: &AppState,
|
||||||
|
session: &AuthenticatedSession,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
action: PolicyAction,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let membership = session
|
||||||
|
.memberships
|
||||||
|
.iter()
|
||||||
|
.find(|membership| membership.workspace.id == *workspace_id)
|
||||||
|
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
|
||||||
|
let actor = SessionActor {
|
||||||
|
user_id: session.user.id.clone(),
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
role: membership.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.service.policy_engine().check(
|
||||||
|
&actor,
|
||||||
|
action,
|
||||||
|
PolicyScope::Workspace(workspace_id.clone()),
|
||||||
|
) {
|
||||||
|
PolicyDecision::Allow => Ok(()),
|
||||||
|
PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_access_audit(
|
||||||
|
state: &AppState,
|
||||||
|
session: &AuthenticatedSession,
|
||||||
|
workspace_id: &crank_core::WorkspaceId,
|
||||||
|
action: &str,
|
||||||
|
target_kind: AuditTargetKind,
|
||||||
|
target_id: String,
|
||||||
|
payload: Value,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.audit_sink()
|
||||||
|
.record(AuditEvent {
|
||||||
|
id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())),
|
||||||
|
occurred_at: OffsetDateTime::now_utc(),
|
||||||
|
actor: AuditActor {
|
||||||
|
user_id: session.user.id.clone(),
|
||||||
|
email: session.user.email.clone(),
|
||||||
|
session_id: Some(session.session_id.clone()),
|
||||||
|
},
|
||||||
|
action: action.to_owned(),
|
||||||
|
target: AuditTarget {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
kind: target_kind,
|
||||||
|
id: target_id,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
source_ip: None,
|
||||||
|
user_agent: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiError::internal(format!("failed to record audit event: {error}")))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, header},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedSession,
|
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
request_context::RequestContext,
|
|
||||||
service::{
|
service::{
|
||||||
AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
|
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||||
PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
|
UpdateAgentPayload,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -54,18 +50,6 @@ pub async fn list_agents(
|
|||||||
Ok(Json(json!({ "items": items })))
|
Ok(Json(json!({ "items": items })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preview_tool_search(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<ToolSearchPreviewPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let items = state
|
|
||||||
.service
|
|
||||||
.preview_tool_search(&path.workspace_id.as_str().into(), payload)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({"items": items})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_agent(
|
pub async fn create_agent(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -81,7 +65,7 @@ pub async fn create_agent(
|
|||||||
pub async fn get_agent(
|
pub async fn get_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let agent = state
|
let agent = state
|
||||||
.service
|
.service
|
||||||
.get_agent(
|
.get_agent(
|
||||||
@@ -89,30 +73,20 @@ pub async fn get_agent(
|
|||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let etag = crate::service::AdminService::agent_state_etag(&agent);
|
Ok(Json(json!(agent)))
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
header::ETAG,
|
|
||||||
header::HeaderValue::from_str(&etag)
|
|
||||||
.map_err(|_| ApiError::internal("invalid agent etag"))?,
|
|
||||||
);
|
|
||||||
Ok((headers, Json(json!(agent))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_agent(
|
pub async fn update_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<UpdateAgentPayload>,
|
Json(payload): Json<UpdateAgentPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let updated = state
|
let updated = state
|
||||||
.service
|
.service
|
||||||
.update_agent(
|
.update_agent(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(updated)))
|
Ok(Json(json!(updated)))
|
||||||
@@ -121,15 +95,12 @@ pub async fn update_agent(
|
|||||||
pub async fn delete_agent(
|
pub async fn delete_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let deleted = state
|
let deleted = state
|
||||||
.service
|
.service
|
||||||
.delete_agent(
|
.delete_agent(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(deleted)))
|
Ok(Json(json!(deleted)))
|
||||||
@@ -153,79 +124,30 @@ pub async fn get_agent_version(
|
|||||||
pub async fn save_agent_bindings(
|
pub async fn save_agent_bindings(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
Json(payload): Json<Vec<AgentBindingPayload>>,
|
||||||
Json(payload): Json<AgentCatalogPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let record = state
|
let record = state
|
||||||
.service
|
.service
|
||||||
.save_agent_bindings(
|
.save_agent_bindings(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(record)))
|
Ok(Json(json!(record)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_agent_precondition(
|
|
||||||
state: &AppState,
|
|
||||||
path: &WorkspaceAgentPath,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> Result<crank_registry::AgentStateExpectation, ApiError> {
|
|
||||||
let agent = state
|
|
||||||
.service
|
|
||||||
.get_agent(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.agent_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let expected = crate::service::AdminService::agent_state_etag(&agent);
|
|
||||||
let provided = headers
|
|
||||||
.get(header::IF_MATCH)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::precondition_required_with_context(
|
|
||||||
"If-Match is required for Agent mutation",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_precondition_required",
|
|
||||||
"current_version": agent.current_draft_version,
|
|
||||||
"latest_published_version": agent.latest_published_version,
|
|
||||||
"catalog_revision": agent.catalog_revision,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if provided != expected {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"Agent state changed; reload before retrying",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_stale_revision",
|
|
||||||
"current_version": agent.current_draft_version,
|
|
||||||
"latest_published_version": agent.latest_published_version,
|
|
||||||
"catalog_revision": agent.catalog_revision,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
crate::service::AdminService::agent_state_expectation(&agent)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn publish_agent(
|
pub async fn publish_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<PublishPayload>,
|
Json(payload): Json<PublishPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let published = state
|
let published = state
|
||||||
.service
|
.service
|
||||||
.publish_agent(
|
.publish_agent(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
payload.version,
|
payload.version,
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(published)))
|
Ok(Json(json!(published)))
|
||||||
@@ -234,15 +156,12 @@ pub async fn publish_agent(
|
|||||||
pub async fn unpublish_agent(
|
pub async fn unpublish_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let updated = state
|
let updated = state
|
||||||
.service
|
.service
|
||||||
.unpublish_agent(
|
.unpublish_agent(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(updated)))
|
Ok(Json(json!(updated)))
|
||||||
@@ -251,15 +170,12 @@ pub async fn unpublish_agent(
|
|||||||
pub async fn archive_agent(
|
pub async fn archive_agent(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
|
||||||
let updated = state
|
let updated = state
|
||||||
.service
|
.service
|
||||||
.archive_agent(
|
.archive_agent(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
Some(&expected_state),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(updated)))
|
Ok(Json(json!(updated)))
|
||||||
@@ -282,19 +198,14 @@ pub async fn list_agent_platform_api_keys(
|
|||||||
pub async fn create_agent_platform_api_key(
|
pub async fn create_agent_platform_api_key(
|
||||||
Path(path): Path<WorkspaceAgentPath>,
|
Path(path): Path<WorkspaceAgentPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Json(payload): Json<PlatformApiKeyPayload>,
|
Json(payload): Json<PlatformApiKeyPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
let created = state
|
let created = state
|
||||||
.service
|
.service
|
||||||
.create_agent_platform_api_key(
|
.create_agent_platform_api_key(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(created)))
|
Ok(Json(json!(created)))
|
||||||
@@ -303,18 +214,13 @@ pub async fn create_agent_platform_api_key(
|
|||||||
pub async fn revoke_agent_platform_api_key(
|
pub async fn revoke_agent_platform_api_key(
|
||||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
state
|
state
|
||||||
.service
|
.service
|
||||||
.revoke_agent_platform_api_key(
|
.revoke_agent_platform_api_key(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
&path.key_id.as_str().into(),
|
&path.key_id.as_str().into(),
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||||
@@ -323,18 +229,13 @@ pub async fn revoke_agent_platform_api_key(
|
|||||||
pub async fn delete_agent_platform_api_key(
|
pub async fn delete_agent_platform_api_key(
|
||||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
state
|
state
|
||||||
.service
|
.service
|
||||||
.delete_agent_platform_api_key(
|
.delete_agent_platform_api_key(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.agent_id.as_str().into(),
|
&path.agent_id.as_str().into(),
|
||||||
&path.key_id.as_str().into(),
|
&path.key_id.as_str().into(),
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -5,52 +5,18 @@ use serde_json::json;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
rate_limit::ClientIdentityBucket,
|
|
||||||
service::{
|
service::{
|
||||||
ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
|
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||||
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn bootstrap_status(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
||||||
Ok(Json(serde_json::json!(
|
|
||||||
state.service.bootstrap_status().await?
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn complete_bootstrap(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
Json(payload): Json<CompleteBootstrapPayload>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
|
||||||
let (session_data, session) = state.service.complete_bootstrap(payload).await?;
|
|
||||||
let cookie_value = format!(
|
|
||||||
"{}.{}",
|
|
||||||
session_data.session_id.as_str(),
|
|
||||||
session_data.value
|
|
||||||
);
|
|
||||||
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
|
|
||||||
|
|
||||||
Ok((jar, Json(serde_json::json!(session))))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
client_bucket: Option<Extension<ClientIdentityBucket>>,
|
|
||||||
Json(payload): Json<LoginPayload>,
|
Json(payload): Json<LoginPayload>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let client_bucket = client_bucket
|
let (session_data, session) = state.service.login(payload).await?;
|
||||||
.as_ref()
|
|
||||||
.map(|Extension(bucket)| bucket.0.as_str())
|
|
||||||
.unwrap_or("anonymous:/api/auth/login");
|
|
||||||
let (session_data, session) = state
|
|
||||||
.service
|
|
||||||
.login_with_client_bucket(payload, client_bucket)
|
|
||||||
.await?;
|
|
||||||
let cookie_value = format!(
|
let cookie_value = format!(
|
||||||
"{}.{}",
|
"{}.{}",
|
||||||
session_data.session_id.as_str(),
|
session_data.session_id.as_str(),
|
||||||
@@ -88,22 +54,6 @@ pub async fn get_session(
|
|||||||
Ok(Json(serde_json::json!(session)))
|
Ok(Json(serde_json::json!(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_session_csrf(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
||||||
let (session_id, session_value) = extract_session_token(&jar)
|
|
||||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
|
||||||
state
|
|
||||||
.service
|
|
||||||
.get_session(&session_id, &session_value)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
|
||||||
let csrf_token = state.service.rotate_session_csrf_token(&session_id).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({ "csrf_token": csrf_token })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_profile(
|
pub async fn get_profile(
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -123,7 +73,6 @@ pub async fn update_profile(
|
|||||||
.service
|
.service
|
||||||
.update_profile(
|
.update_profile(
|
||||||
&session.user.id,
|
&session.user.id,
|
||||||
&session.session_id,
|
|
||||||
session.current_workspace_id.as_ref(),
|
session.current_workspace_id.as_ref(),
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
@@ -154,7 +103,7 @@ pub async fn change_password(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
state
|
state
|
||||||
.service
|
.service
|
||||||
.change_password(&session.user.id, &session.session_id, payload)
|
.change_password(&session.user.id, payload)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
|
||||||
auth::AuthenticatedSession,
|
|
||||||
error::ApiError,
|
|
||||||
request_context::RequestContext,
|
|
||||||
service::{AdminAuditContext, AuthProfilePayload},
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct WorkspacePath {
|
pub struct WorkspacePath {
|
||||||
@@ -38,19 +32,11 @@ pub async fn list_auth_profiles(
|
|||||||
pub async fn create_auth_profile(
|
pub async fn create_auth_profile(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Json(payload): Json<AuthProfilePayload>,
|
Json(payload): Json<AuthProfilePayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
let profile = state
|
let profile = state
|
||||||
.service
|
.service
|
||||||
.create_auth_profile(
|
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
payload,
|
|
||||||
Some(&audit_context),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(profile)))
|
Ok(Json(json!(profile)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Multipart, Path, State, multipart::MultipartRejection},
|
|
||||||
http::HeaderMap,
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use crank_artifacts::MAX_ARTIFACT_BYTES;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale},
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WorkspacePath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WorkspaceImportPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub job_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn preview_openapi_import(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
multipart: Result<Multipart, MultipartRejection>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let locale = openapi_upload_locale(&headers);
|
|
||||||
let upload = parse_openapi_upload(
|
|
||||||
multipart.map_err(|rejection| multipart_rejection(locale, rejection))?,
|
|
||||||
locale,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let preview = state
|
|
||||||
.service
|
|
||||||
.preview_openapi_import(&path.workspace_id.as_str().into(), upload)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(preview)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn multipart_rejection(locale: OpenApiUploadLocale, rejection: MultipartRejection) -> ApiError {
|
|
||||||
if rejection.into_response().status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
|
|
||||||
ApiError::openapi_upload(locale, "file_too_large")
|
|
||||||
} else {
|
|
||||||
ApiError::openapi_upload(locale, "malformed_multipart")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse_openapi_upload(
|
|
||||||
mut multipart: Multipart,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<OpenApiUpload, ApiError> {
|
|
||||||
let mut upload = None;
|
|
||||||
while let Some(field) = multipart
|
|
||||||
.next_field()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
|
|
||||||
{
|
|
||||||
if upload.is_some() || field.name() != Some("file") {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "malformed_multipart"));
|
|
||||||
}
|
|
||||||
let filename = field
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_filename"))?;
|
|
||||||
let mime_type = field
|
|
||||||
.content_type()
|
|
||||||
// Axum exposes the raw header value here. Persist only the MIME
|
|
||||||
// essence so parameters and case cannot make route and service
|
|
||||||
// validation disagree.
|
|
||||||
.map(|mime| {
|
|
||||||
mime.split(';')
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
})
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_media_type"))?;
|
|
||||||
if !valid_upload_type(filename, &mime_type) {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "invalid_media_type"));
|
|
||||||
}
|
|
||||||
let mut bytes = Vec::with_capacity(8 * 1024);
|
|
||||||
let mut field = field;
|
|
||||||
while let Some(chunk) = field
|
|
||||||
.chunk()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
|
|
||||||
{
|
|
||||||
if bytes.len().saturating_add(chunk.len()) > MAX_ARTIFACT_BYTES {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "file_too_large"));
|
|
||||||
}
|
|
||||||
bytes.extend_from_slice(&chunk);
|
|
||||||
}
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "empty_file"));
|
|
||||||
}
|
|
||||||
if std::str::from_utf8(&bytes).is_err() {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "invalid_utf8"));
|
|
||||||
}
|
|
||||||
upload = Some(OpenApiUpload {
|
|
||||||
bytes,
|
|
||||||
mime_type,
|
|
||||||
locale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
upload.ok_or_else(|| ApiError::openapi_upload(locale, "missing_file"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_upload_type(filename: &str, mime_type: &str) -> bool {
|
|
||||||
let filename = filename.to_ascii_lowercase();
|
|
||||||
let mime_type = mime_type.to_ascii_lowercase();
|
|
||||||
match filename.rsplit_once('.') {
|
|
||||||
Some((_, "yaml" | "yml")) => matches!(
|
|
||||||
mime_type.as_str(),
|
|
||||||
"application/yaml"
|
|
||||||
| "application/x-yaml"
|
|
||||||
| "text/yaml"
|
|
||||||
| "text/x-yaml"
|
|
||||||
| "application/octet-stream"
|
|
||||||
),
|
|
||||||
Some((_, "json")) => matches!(
|
|
||||||
mime_type.as_str(),
|
|
||||||
"application/json" | "application/openapi+json" | "application/octet-stream"
|
|
||||||
),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn openapi_upload_locale(headers: &HeaderMap) -> OpenApiUploadLocale {
|
|
||||||
let Some(value) = headers
|
|
||||||
.get("accept-language")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
else {
|
|
||||||
return OpenApiUploadLocale::En;
|
|
||||||
};
|
|
||||||
|
|
||||||
// RFC 9110: highest q wins; ties preserve the header's order. Only the
|
|
||||||
// locales served by this endpoint participate in negotiation.
|
|
||||||
let mut preferred = (0_u16, usize::MAX, OpenApiUploadLocale::En);
|
|
||||||
for (index, range) in value.split(',').enumerate() {
|
|
||||||
let mut parts = range.split(';');
|
|
||||||
let language = parts.next().unwrap_or_default().trim();
|
|
||||||
let locale = if language.eq_ignore_ascii_case("ru")
|
|
||||||
|| language.to_ascii_lowercase().starts_with("ru-")
|
|
||||||
{
|
|
||||||
Some(OpenApiUploadLocale::Ru)
|
|
||||||
} else if language.eq_ignore_ascii_case("en")
|
|
||||||
|| language.to_ascii_lowercase().starts_with("en-")
|
|
||||||
|| language == "*"
|
|
||||||
{
|
|
||||||
Some(OpenApiUploadLocale::En)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let Some(locale) = locale else { continue };
|
|
||||||
let quality = match parts
|
|
||||||
.filter_map(|parameter| {
|
|
||||||
let (name, value) = parameter.trim().split_once('=')?;
|
|
||||||
name.eq_ignore_ascii_case("q").then_some(value.trim())
|
|
||||||
})
|
|
||||||
.next()
|
|
||||||
{
|
|
||||||
None => Some(1_000_u16),
|
|
||||||
Some(value) => value
|
|
||||||
.parse::<f32>()
|
|
||||||
.ok()
|
|
||||||
.filter(|quality| (0.0..=1.0).contains(quality) && *quality > 0.0)
|
|
||||||
.map(|quality| (quality * 1_000.0).round() as u16),
|
|
||||||
};
|
|
||||||
let Some(quality) = quality else { continue };
|
|
||||||
if quality > preferred.0 || (quality == preferred.0 && index < preferred.1) {
|
|
||||||
preferred = (quality, index, locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preferred.2
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_openapi_import(
|
|
||||||
Path(path): Path<WorkspaceImportPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<OpenApiImportCreatePayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let imported = state
|
|
||||||
.service
|
|
||||||
.create_openapi_import_with_locale(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.job_id.as_str().into(),
|
|
||||||
payload,
|
|
||||||
openapi_upload_locale(&headers),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(imported)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use axum::http::{HeaderMap, HeaderValue};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn locale(value: &str) -> OpenApiUploadLocale {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("accept-language", HeaderValue::from_str(value).unwrap());
|
|
||||||
openapi_upload_locale(&headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accept_language_honours_quality_zero_and_header_order() {
|
|
||||||
assert_eq!(locale("ru;q=0, en;q=0.5"), OpenApiUploadLocale::En);
|
|
||||||
assert_eq!(locale("en;q=0.5, ru;q=0.5"), OpenApiUploadLocale::En);
|
|
||||||
assert_eq!(locale("ru-RU;q=0.9, en;q=1"), OpenApiUploadLocale::En);
|
|
||||||
assert_eq!(locale("ru, en;q=0.5"), OpenApiUploadLocale::Ru);
|
|
||||||
assert_eq!(locale("ru;q=bad, en;q=0.5"), OpenApiUploadLocale::En);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use axum::{Json, extract::State};
|
||||||
|
use crank_core::{
|
||||||
|
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole,
|
||||||
|
TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
pub async fn issue_agent_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<IssueAgentTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let edition = state.service.capability_profile().capabilities().edition;
|
||||||
|
let grant_type = payload.grant_type.clone();
|
||||||
|
let response = state
|
||||||
|
.service
|
||||||
|
.token_issuer()
|
||||||
|
.issue_short_lived(payload, &community_token_issuer_actor())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_token_issuer_error(
|
||||||
|
error,
|
||||||
|
edition,
|
||||||
|
MachineAccessMode::ShortLivedToken,
|
||||||
|
json!({
|
||||||
|
"grant_type": grant_type,
|
||||||
|
"upgrade_required": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Json(serde_json::json!(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_one_time_agent_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let edition = state.service.capability_profile().capabilities().edition;
|
||||||
|
let operation_id = payload.operation_id.as_str().to_owned();
|
||||||
|
let response = state
|
||||||
|
.service
|
||||||
|
.token_issuer()
|
||||||
|
.issue_one_time(payload, &community_token_issuer_actor())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_token_issuer_error(
|
||||||
|
error,
|
||||||
|
edition,
|
||||||
|
MachineAccessMode::OneTimeToken,
|
||||||
|
json!({
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"upgrade_required": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Json(serde_json::json!(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn community_token_issuer_actor() -> TokenIssuerActor {
|
||||||
|
TokenIssuerActor {
|
||||||
|
user_id: UserId::new("user_community_public"),
|
||||||
|
workspace_id: WorkspaceId::new("ws_community_public"),
|
||||||
|
role: MembershipRole::Viewer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_token_issuer_error(
|
||||||
|
error: TokenIssuerError,
|
||||||
|
edition: crank_core::ProductEdition,
|
||||||
|
machine_access_mode: MachineAccessMode,
|
||||||
|
extra_context: serde_json::Value,
|
||||||
|
) -> ApiError {
|
||||||
|
match error {
|
||||||
|
TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context(
|
||||||
|
match machine_access_mode {
|
||||||
|
MachineAccessMode::ShortLivedToken => {
|
||||||
|
"short-lived machine access is not available in Community"
|
||||||
|
}
|
||||||
|
MachineAccessMode::OneTimeToken => {
|
||||||
|
"one-time machine access is not available in Community"
|
||||||
|
}
|
||||||
|
MachineAccessMode::StaticAgentKey => {
|
||||||
|
"static agent key machine access is not available for token issue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
merge_machine_auth_context(edition, machine_access_mode, extra_context),
|
||||||
|
),
|
||||||
|
TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context(
|
||||||
|
"invalid machine token grant",
|
||||||
|
json!({ "reason": reason }),
|
||||||
|
),
|
||||||
|
TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"),
|
||||||
|
TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"),
|
||||||
|
TokenIssuerError::OperationNotPublishedForAgent => {
|
||||||
|
ApiError::validation("operation is not published for agent")
|
||||||
|
}
|
||||||
|
TokenIssuerError::RegistryFailure(details) => {
|
||||||
|
ApiError::internal(format!("token issuer registry failure: {details}"))
|
||||||
|
}
|
||||||
|
TokenIssuerError::ReplayGuardFailure(details) => {
|
||||||
|
ApiError::internal(format!("token issuer replay guard failure: {details}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_machine_auth_context(
|
||||||
|
edition: crank_core::ProductEdition,
|
||||||
|
machine_access_mode: MachineAccessMode,
|
||||||
|
extra_context: serde_json::Value,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut context = json!({
|
||||||
|
"edition": edition,
|
||||||
|
"machine_access_mode": machine_access_mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) {
|
||||||
|
for (key, value) in extra {
|
||||||
|
base.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context
|
||||||
|
}
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{
|
|
||||||
HeaderValue, StatusCode,
|
|
||||||
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
|
||||||
},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
routes::access::WorkspacePath,
|
routes::access::WorkspacePath,
|
||||||
service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
service::{LogsQuery, UsageRequestQuery},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,12 +17,6 @@ pub struct WorkspaceLogPath {
|
|||||||
pub log_id: String,
|
pub log_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct WorkspaceApprovalPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub approval_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct WorkspaceOperationUsagePath {
|
pub struct WorkspaceOperationUsagePath {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -49,49 +38,7 @@ pub async fn list_logs(
|
|||||||
.service
|
.service
|
||||||
.list_logs(&path.workspace_id.as_str().into(), query)
|
.list_logs(&path.workspace_id.as_str().into(), query)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(items)))
|
Ok(Json(json!({ "items": items })))
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn export_logs_csv(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
Query(query): Query<LogsQuery>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let csv = state
|
|
||||||
.service
|
|
||||||
.export_logs_csv(&path.workspace_id.as_str().into(), query)
|
|
||||||
.await?;
|
|
||||||
let mut response = (StatusCode::OK, csv).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_DISPOSITION,
|
|
||||||
HeaderValue::from_static("attachment; filename=\"crank-invocation-history.csv\""),
|
|
||||||
);
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn export_usage_csv(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
Query(query): Query<UsageRequestQuery>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Response, ApiError> {
|
|
||||||
let csv = state
|
|
||||||
.service
|
|
||||||
.export_usage_csv(&path.workspace_id.as_str().into(), query)
|
|
||||||
.await?;
|
|
||||||
let mut response = (StatusCode::OK, csv).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_DISPOSITION,
|
|
||||||
HeaderValue::from_static("attachment; filename=\"crank-usage.csv\""),
|
|
||||||
);
|
|
||||||
Ok(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_log(
|
pub async fn get_log(
|
||||||
@@ -108,64 +55,6 @@ pub async fn get_log(
|
|||||||
Ok(Json(json!(item)))
|
Ok(Json(json!(item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_approvals(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
Query(query): Query<ApprovalsQuery>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let items = state
|
|
||||||
.service
|
|
||||||
.list_approvals(&path.workspace_id.as_str().into(), query)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({ "items": items })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_approval(
|
|
||||||
Path(path): Path<WorkspaceApprovalPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let item = state
|
|
||||||
.service
|
|
||||||
.get_approval(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.approval_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(item)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn approve_approval(
|
|
||||||
Path(path): Path<WorkspaceApprovalPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<ApprovalDecisionPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let item = state
|
|
||||||
.service
|
|
||||||
.approve_approval(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.approval_id.as_str().into(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(item)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn deny_approval(
|
|
||||||
Path(path): Path<WorkspaceApprovalPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<ApprovalDecisionPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let item = state
|
|
||||||
.service
|
|
||||||
.deny_approval(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.approval_id.as_str().into(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(item)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_usage(
|
pub async fn get_usage(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
Query(query): Query<UsageRequestQuery>,
|
Query(query): Query<UsageRequestQuery>,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
OnboardingEventPayload, OnboardingEventResponse, OnboardingResponse,
|
|
||||||
ResetOnboardingSelectionPayload, ResetOnboardingSelectionResponse,
|
|
||||||
},
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct WorkspaceOnboardingPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reset_onboarding_selection(
|
|
||||||
Path(path): Path<WorkspaceOnboardingPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<ResetOnboardingSelectionPayload>,
|
|
||||||
) -> Result<Json<ResetOnboardingSelectionResponse>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
state
|
|
||||||
.service
|
|
||||||
.reset_onboarding_selection(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
payload.expected_revision,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_onboarding(
|
|
||||||
Path(path): Path<WorkspaceOnboardingPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<OnboardingResponse>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
state
|
|
||||||
.service
|
|
||||||
.get_onboarding(&path.workspace_id.as_str().into())
|
|
||||||
.await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn record_onboarding_event(
|
|
||||||
Path(path): Path<WorkspaceOnboardingPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<OnboardingEventPayload>,
|
|
||||||
) -> Result<Json<OnboardingEventResponse>, ApiError> {
|
|
||||||
Ok(Json(
|
|
||||||
state
|
|
||||||
.service
|
|
||||||
.record_onboarding_event(&path.workspace_id.as_str().into(), payload)
|
|
||||||
.await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Extension, Path, Query, State, rejection::StringRejection},
|
extract::{Extension, Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use crank_registry::OperationStateExpectation;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -65,22 +64,10 @@ pub async fn create_operation(
|
|||||||
Ok(Json(json!(created)))
|
Ok(Json(json!(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn analyze_operation_quality(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<OperationPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let report = state
|
|
||||||
.service
|
|
||||||
.analyze_operation_quality(&path.workspace_id.as_str().into(), payload)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(report)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_operation(
|
pub async fn get_operation(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let operation = state
|
let operation = state
|
||||||
.service
|
.service
|
||||||
.get_operation(
|
.get_operation(
|
||||||
@@ -88,30 +75,20 @@ pub async fn get_operation(
|
|||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let etag = crate::service::AdminService::operation_state_etag(&operation);
|
Ok(Json(json!(operation)))
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
header::ETAG,
|
|
||||||
header::HeaderValue::from_str(&etag)
|
|
||||||
.map_err(|_| ApiError::internal("invalid operation etag"))?,
|
|
||||||
);
|
|
||||||
Ok((headers, Json(json!(operation))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_operation(
|
pub async fn update_operation(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<UpdateOperationPayload>,
|
Json(payload): Json<UpdateOperationPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
|
||||||
let result = state
|
let result = state
|
||||||
.service
|
.service
|
||||||
.update_operation(
|
.update_operation(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
&expected,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(result)))
|
Ok(Json(json!(result)))
|
||||||
@@ -120,15 +97,12 @@ pub async fn update_operation(
|
|||||||
pub async fn delete_operation(
|
pub async fn delete_operation(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
|
||||||
let result = state
|
let result = state
|
||||||
.service
|
.service
|
||||||
.delete_operation(
|
.delete_operation(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
&expected,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(result)))
|
Ok(Json(json!(result)))
|
||||||
@@ -137,7 +111,7 @@ pub async fn delete_operation(
|
|||||||
pub async fn get_operation_version(
|
pub async fn get_operation_version(
|
||||||
Path(path): Path<WorkspaceOperationVersionPath>,
|
Path(path): Path<WorkspaceOperationVersionPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let version = state
|
let version = state
|
||||||
.service
|
.service
|
||||||
.get_operation_version(
|
.get_operation_version(
|
||||||
@@ -146,30 +120,20 @@ pub async fn get_operation_version(
|
|||||||
path.version,
|
path.version,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let etag = crate::service::AdminService::operation_version_etag(&version)?;
|
Ok(Json(json!(version)))
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
header::ETAG,
|
|
||||||
header::HeaderValue::from_str(&etag)
|
|
||||||
.map_err(|_| ApiError::internal("invalid operation version etag"))?,
|
|
||||||
);
|
|
||||||
Ok((headers, Json(json!(version))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_version(
|
pub async fn create_version(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<NewVersionPayload>,
|
Json(payload): Json<NewVersionPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
|
||||||
let created = state
|
let created = state
|
||||||
.service
|
.service
|
||||||
.create_version(
|
.create_version(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
&expected,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(created)))
|
Ok(Json(json!(created)))
|
||||||
@@ -178,17 +142,14 @@ pub async fn create_version(
|
|||||||
pub async fn publish_operation(
|
pub async fn publish_operation(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<PublishPayload>,
|
Json(payload): Json<PublishPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
|
||||||
let published = state
|
let published = state
|
||||||
.service
|
.service
|
||||||
.publish_operation(
|
.publish_operation(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
payload.version,
|
payload.version,
|
||||||
&expected,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(published)))
|
Ok(Json(json!(published)))
|
||||||
@@ -197,63 +158,17 @@ pub async fn publish_operation(
|
|||||||
pub async fn archive_operation(
|
pub async fn archive_operation(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
|
||||||
let archived = state
|
let archived = state
|
||||||
.service
|
.service
|
||||||
.archive_operation(
|
.archive_operation(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
&expected,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(archived)))
|
Ok(Json(json!(archived)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_operation_precondition(
|
|
||||||
state: &AppState,
|
|
||||||
path: &WorkspaceOperationPath,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> Result<OperationStateExpectation, ApiError> {
|
|
||||||
let detail = state
|
|
||||||
.service
|
|
||||||
.get_operation(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.operation_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let expected = crate::service::AdminService::operation_state_etag(&detail);
|
|
||||||
let provided = headers
|
|
||||||
.get(header::IF_MATCH)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::precondition_required_with_context(
|
|
||||||
"If-Match is required for Operation mutation",
|
|
||||||
json!({
|
|
||||||
"error_code": "operation_precondition_required",
|
|
||||||
"current_version": detail.current_draft_version,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if provided != expected {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"Operation state changed; reload before retrying",
|
|
||||||
json!({
|
|
||||||
"error_code": "operation_stale_version",
|
|
||||||
"current_version": detail.current_draft_version,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(OperationStateExpectation {
|
|
||||||
current_draft_version: detail.current_draft_version,
|
|
||||||
status: detail.status,
|
|
||||||
latest_published_version: detail.latest_published_version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_test(
|
pub async fn run_test(
|
||||||
Path(path): Path<WorkspaceOperationPath>,
|
Path(path): Path<WorkspaceOperationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -266,7 +181,7 @@ pub async fn run_test(
|
|||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.operation_id.as_str().into(),
|
&path.operation_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
&request_context.correlation,
|
&request_context.request_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(result)))
|
Ok(Json(json!(result)))
|
||||||
@@ -358,33 +273,11 @@ pub async fn import_operation(
|
|||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
Query(query): Query<ImportQuery>,
|
Query(query): Query<ImportQuery>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
body: String,
|
||||||
body: Result<String, StringRejection>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let body = body.map_err(|rejection| match rejection {
|
|
||||||
StringRejection::InvalidUtf8(_) => ApiError::unprocessable_with_context(
|
|
||||||
"operation yaml is not valid UTF-8",
|
|
||||||
json!({ "error_code": "operation_yaml_invalid" }),
|
|
||||||
),
|
|
||||||
StringRejection::FailedToBufferBody(_) => ApiError::payload_too_large_with_context(
|
|
||||||
"operation yaml exceeds the 256 KiB limit",
|
|
||||||
json!({ "error_code": "operation_yaml_too_large" }),
|
|
||||||
),
|
|
||||||
_ => ApiError::unprocessable_with_context(
|
|
||||||
"operation yaml body is invalid",
|
|
||||||
json!({ "error_code": "operation_yaml_invalid" }),
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
let imported = state
|
let imported = state
|
||||||
.service
|
.service
|
||||||
.import_operation(
|
.import_operation(&path.workspace_id.as_str().into(), query, &body)
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
query,
|
|
||||||
&body,
|
|
||||||
headers
|
|
||||||
.get(header::IF_MATCH)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(imported)))
|
Ok(Json(json!(imported)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use serde_json::{Value, json};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedSession,
|
auth::AuthenticatedSession,
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
request_context::RequestContext,
|
service::{RotateSecretPayload, SecretPayload},
|
||||||
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
|
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,18 +38,14 @@ pub async fn create_secret(
|
|||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Json(payload): Json<SecretPayload>,
|
Json(payload): Json<SecretPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
let secret = state
|
let secret = state
|
||||||
.service
|
.service
|
||||||
.create_secret(
|
.create_secret(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
Some(&session.user.id),
|
Some(&session.user.id),
|
||||||
payload,
|
payload,
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(secret)))
|
Ok(Json(json!(secret)))
|
||||||
@@ -74,11 +69,8 @@ pub async fn rotate_secret(
|
|||||||
Path(path): Path<WorkspaceSecretPath>,
|
Path(path): Path<WorkspaceSecretPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Json(payload): Json<RotateSecretPayload>,
|
Json(payload): Json<RotateSecretPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
let secret = state
|
let secret = state
|
||||||
.service
|
.service
|
||||||
.rotate_secret(
|
.rotate_secret(
|
||||||
@@ -86,7 +78,6 @@ pub async fn rotate_secret(
|
|||||||
&path.secret_id.as_str().into(),
|
&path.secret_id.as_str().into(),
|
||||||
Some(&session.user.id),
|
Some(&session.user.id),
|
||||||
payload,
|
payload,
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(secret)))
|
Ok(Json(json!(secret)))
|
||||||
@@ -95,17 +86,12 @@ pub async fn rotate_secret(
|
|||||||
pub async fn delete_secret(
|
pub async fn delete_secret(
|
||||||
Path(path): Path<WorkspaceSecretPath>,
|
Path(path): Path<WorkspaceSecretPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let audit_context =
|
|
||||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
|
||||||
state
|
state
|
||||||
.service
|
.service
|
||||||
.delete_secret(
|
.delete_secret(
|
||||||
&path.workspace_id.as_str().into(),
|
&path.workspace_id.as_str().into(),
|
||||||
&path.secret_id.as_str().into(),
|
&path.secret_id.as_str().into(),
|
||||||
Some(&audit_context),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({ "ok": true })))
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState};
|
||||||
|
|
||||||
|
pub async fn list_protocol_capabilities(
|
||||||
|
Path(_path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"items": state.service.list_protocol_capabilities().await
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::{error::ApiError, service::UpstreamPayload, state::AppState};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WorkspacePath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WorkspaceUpstreamPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub upstream_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_upstreams(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let items = state
|
|
||||||
.service
|
|
||||||
.list_workspace_upstreams(&path.workspace_id.as_str().into())
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({ "items": items })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_upstream(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<UpstreamPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let upstream = state
|
|
||||||
.service
|
|
||||||
.save_workspace_upstream(&path.workspace_id.as_str().into(), None, payload)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(upstream)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_upstream(
|
|
||||||
Path(path): Path<WorkspaceUpstreamPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<UpstreamPayload>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let upstream = state
|
|
||||||
.service
|
|
||||||
.save_workspace_upstream(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
Some(&path.upstream_id.as_str().into()),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(upstream)))
|
|
||||||
}
|
|
||||||
+3911
-799
File diff suppressed because it is too large
Load Diff
@@ -1,744 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_core::{
|
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool,
|
|
||||||
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
|
||||||
PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
|
|
||||||
UsageQuery,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
||||||
use tracing::{info, instrument};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
|
||||||
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
|
||||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string,
|
|
||||||
today_start_utc,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub fn agent_state_etag(agent: &AgentSummaryView) -> String {
|
|
||||||
let policy =
|
|
||||||
serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned());
|
|
||||||
let operation_ids = agent.operation_ids.join(",");
|
|
||||||
let material = format!(
|
|
||||||
"agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}",
|
|
||||||
agent.workspace_id,
|
|
||||||
agent.id,
|
|
||||||
agent.slug,
|
|
||||||
agent.display_name,
|
|
||||||
agent.description,
|
|
||||||
agent.updated_at,
|
|
||||||
agent.current_draft_version,
|
|
||||||
agent.status,
|
|
||||||
agent.latest_published_version,
|
|
||||||
agent.catalog_revision,
|
|
||||||
agent.operation_count,
|
|
||||||
operation_ids,
|
|
||||||
policy,
|
|
||||||
agent.key_count
|
|
||||||
);
|
|
||||||
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn agent_state_expectation(
|
|
||||||
agent: &AgentSummaryView,
|
|
||||||
) -> Result<AgentStateExpectation, ApiError> {
|
|
||||||
let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339)
|
|
||||||
.map_err(|_| ApiError::internal("invalid agent state timestamp"))?;
|
|
||||||
Ok(AgentStateExpectation {
|
|
||||||
status: agent.status,
|
|
||||||
current_draft_version: agent.current_draft_version,
|
|
||||||
latest_published_version: agent.latest_published_version,
|
|
||||||
catalog_revision: agent.catalog_revision,
|
|
||||||
updated_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
|
||||||
pub async fn preview_tool_search(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: ToolSearchPreviewPayload,
|
|
||||||
) -> Result<Vec<crank_core::ToolSearchMatch>, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
if payload.tool_selection_policy.mode != ToolAccessMode::Search {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"tool search preview requires search mode",
|
|
||||||
json!({"field": "tool_selection_policy.mode"}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let bindings = payload
|
|
||||||
.bindings
|
|
||||||
.iter()
|
|
||||||
.map(|binding| AgentOperationBinding {
|
|
||||||
operation_id: OperationId::new(binding.operation_id.clone()),
|
|
||||||
operation_version: binding.operation_version,
|
|
||||||
tool_name: binding.tool_name.clone(),
|
|
||||||
tool_title: binding.tool_title.clone(),
|
|
||||||
tool_description_override: binding.tool_description_override.clone(),
|
|
||||||
enabled: binding.enabled,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?;
|
|
||||||
|
|
||||||
let mut tools = Vec::new();
|
|
||||||
for binding in bindings.iter().filter(|binding| binding.enabled) {
|
|
||||||
let version = self
|
|
||||||
.registry
|
|
||||||
.get_operation_version(
|
|
||||||
workspace_id,
|
|
||||||
&binding.operation_id,
|
|
||||||
binding.operation_version,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("operation {} was not found", binding.operation_id.as_str()),
|
|
||||||
json!({"operation_id": binding.operation_id.as_str()}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let groups = payload
|
|
||||||
.tool_selection_policy
|
|
||||||
.groups
|
|
||||||
.iter()
|
|
||||||
.filter(|group| {
|
|
||||||
group
|
|
||||||
.tool_names
|
|
||||||
.iter()
|
|
||||||
.any(|name| name == &binding.tool_name)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
tools.push(SearchableTool {
|
|
||||||
name: binding.tool_name.clone(),
|
|
||||||
title: binding.tool_title.clone(),
|
|
||||||
description: binding
|
|
||||||
.tool_description_override
|
|
||||||
.clone()
|
|
||||||
.unwrap_or(version.snapshot.tool_description.description),
|
|
||||||
input_schema: serde_json::Value::Null,
|
|
||||||
group_ids: groups.iter().map(|group| group.id.clone()).collect(),
|
|
||||||
group_context: groups
|
|
||||||
.iter()
|
|
||||||
.map(|group| format!("{} {}", group.name, group.description))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" "),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let max_results = payload.tool_selection_policy.search.max_results;
|
|
||||||
Ok(search_tool_catalog(
|
|
||||||
&tools,
|
|
||||||
&payload.query,
|
|
||||||
&payload.group_ids,
|
|
||||||
max_results,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn list_agents(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
) -> Result<Vec<AgentSummaryView>, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let workspace = self.get_workspace(workspace_id).await?;
|
|
||||||
let summaries = self.registry.list_agents(workspace_id).await?;
|
|
||||||
let usage_start = today_start_utc()?;
|
|
||||||
let usage_end = now_string()?;
|
|
||||||
let usage = self
|
|
||||||
.registry
|
|
||||||
.list_usage_by_agent(UsageQuery {
|
|
||||||
workspace_id,
|
|
||||||
period: UsagePeriod::Last24Hours,
|
|
||||||
source: None,
|
|
||||||
created_after: &usage_start,
|
|
||||||
created_before: &usage_end,
|
|
||||||
bucket: UsageBucket::Hour,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
let calls_today = usage
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| (item.agent_id.as_str().to_owned(), item.calls_total))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
let key_counts = self
|
|
||||||
.registry
|
|
||||||
.list_platform_api_keys(workspace_id)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.fold(BTreeMap::new(), |mut counts, record| {
|
|
||||||
if let Some(agent_id) = record.api_key.agent_id {
|
|
||||||
*counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1;
|
|
||||||
}
|
|
||||||
counts
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut items = Vec::with_capacity(summaries.len());
|
|
||||||
for summary in summaries {
|
|
||||||
let version = self
|
|
||||||
.get_agent_version(workspace_id, &summary.id, summary.current_draft_version)
|
|
||||||
.await?;
|
|
||||||
let operation_ids = version
|
|
||||||
.bindings
|
|
||||||
.iter()
|
|
||||||
.map(|binding| binding.operation_id.as_str().to_owned())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
items.push(AgentSummaryView {
|
|
||||||
operation_count: operation_ids.len(),
|
|
||||||
operation_ids,
|
|
||||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
|
||||||
key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0),
|
|
||||||
calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0),
|
|
||||||
mcp_endpoint: agent_mcp_endpoint(
|
|
||||||
workspace.workspace.slug.as_str(),
|
|
||||||
summary.slug.as_str(),
|
|
||||||
),
|
|
||||||
..map_agent_summary_view(summary)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
) -> Result<AgentSummaryView, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let workspace = self.get_workspace(workspace_id).await?;
|
|
||||||
let summary = self
|
|
||||||
.registry
|
|
||||||
.get_agent_summary(workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let version = self
|
|
||||||
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
|
|
||||||
.await?;
|
|
||||||
let operation_ids = version
|
|
||||||
.bindings
|
|
||||||
.iter()
|
|
||||||
.map(|binding| binding.operation_id.as_str().to_owned())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let usage_start = today_start_utc()?;
|
|
||||||
let usage_end = now_string()?;
|
|
||||||
let usage = self
|
|
||||||
.registry
|
|
||||||
.get_usage_for_agent(
|
|
||||||
UsageQuery {
|
|
||||||
workspace_id,
|
|
||||||
period: UsagePeriod::Last24Hours,
|
|
||||||
source: None,
|
|
||||||
created_after: &usage_start,
|
|
||||||
created_before: &usage_end,
|
|
||||||
bucket: UsageBucket::Hour,
|
|
||||||
},
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let key_count = self
|
|
||||||
.registry
|
|
||||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.len();
|
|
||||||
|
|
||||||
Ok(AgentSummaryView {
|
|
||||||
operation_count: operation_ids.len(),
|
|
||||||
operation_ids,
|
|
||||||
tool_selection_policy: version.snapshot.tool_selection_policy,
|
|
||||||
key_count,
|
|
||||||
calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0),
|
|
||||||
mcp_endpoint: agent_mcp_endpoint(
|
|
||||||
workspace.workspace.slug.as_str(),
|
|
||||||
summary.slug.as_str(),
|
|
||||||
),
|
|
||||||
..map_agent_summary_view(summary)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_agent_version(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
version: u32,
|
|
||||||
) -> Result<AgentVersionRecord, ApiError> {
|
|
||||||
self.registry
|
|
||||||
.get_agent_version(workspace_id, agent_id, version)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!(
|
|
||||||
"agent version {version} for {} was not found",
|
|
||||||
agent_id.as_str()
|
|
||||||
),
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id.as_str(),
|
|
||||||
"version": version,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_slug = %payload.slug))]
|
|
||||||
pub async fn create_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: AgentPayload,
|
|
||||||
) -> Result<CreatedAgentResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
|
|
||||||
if self
|
|
||||||
.find_agent_by_slug(workspace_id, &payload.slug)
|
|
||||||
.await?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
format!("agent with slug {} already exists", payload.slug),
|
|
||||||
json!({ "slug": payload.slug }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let agent_id = AgentId::new(new_prefixed_id("agent"));
|
|
||||||
let agent = Agent {
|
|
||||||
id: agent_id.clone(),
|
|
||||||
workspace_id: workspace_id.clone(),
|
|
||||||
slug: payload.slug,
|
|
||||||
display_name: payload.display_name,
|
|
||||||
description: payload.description,
|
|
||||||
status: AgentStatus::Draft,
|
|
||||||
current_draft_version: 1,
|
|
||||||
latest_published_version: None,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
published_at: None,
|
|
||||||
};
|
|
||||||
let version = AgentVersion {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
version: 1,
|
|
||||||
status: AgentStatus::Draft,
|
|
||||||
instructions: payload.instructions,
|
|
||||||
tool_selection_policy: payload.tool_selection_policy,
|
|
||||||
created_at: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.create_agent(CreateAgentRequest {
|
|
||||||
agent: &agent,
|
|
||||||
version: &version,
|
|
||||||
bindings: &[],
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
info!(
|
|
||||||
name: "admin.agent.created",
|
|
||||||
agent_id = %agent_id.as_str(),
|
|
||||||
version = 1,
|
|
||||||
"agent created"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(CreatedAgentResponse {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
version: 1,
|
|
||||||
status: AgentStatus::Draft,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
|
||||||
pub async fn update_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
payload: UpdateAgentPayload,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<AgentMutationResult, ApiError> {
|
|
||||||
let existing = self
|
|
||||||
.registry
|
|
||||||
.get_agent_summary(workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if existing.status == AgentStatus::Published
|
|
||||||
&& (payload.slug != existing.slug
|
|
||||||
|| payload.display_name != existing.display_name
|
|
||||||
|| payload.description != existing.description)
|
|
||||||
{
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"published Agent summary is immutable; unpublish before editing",
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id.as_str(),
|
|
||||||
"error_code": "agent_published_summary_immutable",
|
|
||||||
"recovery": "unpublish_edit_publish"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.slug != existing.slug
|
|
||||||
&& self
|
|
||||||
.find_agent_by_slug(workspace_id, &payload.slug)
|
|
||||||
.await?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
format!("agent with slug {} already exists", payload.slug),
|
|
||||||
json!({ "slug": payload.slug }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated_at = OffsetDateTime::now_utc();
|
|
||||||
self.registry
|
|
||||||
.update_agent_summary(
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
UpdateAgentSummaryRequest {
|
|
||||||
slug: &payload.slug,
|
|
||||||
display_name: &payload.display_name,
|
|
||||||
description: &payload.description,
|
|
||||||
updated_at: &updated_at,
|
|
||||||
expected_state,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(AgentMutationResult {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
updated_at: format_timestamp(updated_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
|
||||||
pub async fn delete_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<AgentMutationResult, ApiError> {
|
|
||||||
let existing = self
|
|
||||||
.registry
|
|
||||||
.get_agent_summary(workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if existing.latest_published_version.is_some() {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"published Agent cannot be deleted; archive or unpublish it instead",
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id.as_str(),
|
|
||||||
"error_code": "agent_delete_forbidden",
|
|
||||||
"recovery": "archive_or_unpublish"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.delete_agent(workspace_id, agent_id, expected_state)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(AgentMutationResult {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
updated_at: format_timestamp(existing.updated_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
|
||||||
pub async fn save_agent_bindings(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
payload: AgentCatalogPayload,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<AgentVersionRecord, ApiError> {
|
|
||||||
let current_version = self
|
|
||||||
.ensure_editable_agent_version(workspace_id, agent_id)
|
|
||||||
.await?;
|
|
||||||
let (payload, requested_policy) = payload.into_parts();
|
|
||||||
let bindings = payload
|
|
||||||
.into_iter()
|
|
||||||
.map(|binding| AgentOperationBinding {
|
|
||||||
operation_id: OperationId::new(binding.operation_id),
|
|
||||||
operation_version: binding.operation_version,
|
|
||||||
tool_name: binding.tool_name,
|
|
||||||
tool_title: binding.tool_title,
|
|
||||||
tool_description_override: binding.tool_description_override,
|
|
||||||
enabled: binding.enabled,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
self.validate_exact_published_agent_bindings(workspace_id, &bindings)
|
|
||||||
.await?;
|
|
||||||
let tool_selection_policy = requested_policy
|
|
||||||
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
|
|
||||||
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.save_agent_catalog_config(SaveAgentCatalogConfigRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
agent_version: current_version.version,
|
|
||||||
bindings: &bindings,
|
|
||||||
tool_selection_policy: &tool_selection_policy,
|
|
||||||
expected_state,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
info!(
|
|
||||||
name: "admin.agent.bindings_saved",
|
|
||||||
agent_id = %agent_id.as_str(),
|
|
||||||
version = current_version.version,
|
|
||||||
binding_count = bindings.len(),
|
|
||||||
"agent bindings saved"
|
|
||||||
);
|
|
||||||
|
|
||||||
self.get_agent_version(workspace_id, agent_id, current_version.version)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_editable_agent_version(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
) -> Result<AgentVersionRecord, ApiError> {
|
|
||||||
let agent = self.get_agent(workspace_id, agent_id).await?;
|
|
||||||
let current = self
|
|
||||||
.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
|
|
||||||
.await?;
|
|
||||||
if agent.latest_published_version != Some(agent.current_draft_version) {
|
|
||||||
return Ok(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let draft = AgentVersion {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
version: current.version + 1,
|
|
||||||
status: AgentStatus::Draft,
|
|
||||||
instructions: current.snapshot.instructions.clone(),
|
|
||||||
tool_selection_policy: current.snapshot.tool_selection_policy.clone(),
|
|
||||||
created_at: now,
|
|
||||||
};
|
|
||||||
self.registry
|
|
||||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
version: &draft,
|
|
||||||
bindings: ¤t.bindings,
|
|
||||||
updated_at: &now,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
self.get_agent_version(workspace_id, agent_id, draft.version)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), version))]
|
|
||||||
pub async fn publish_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
version: u32,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<PublishAgentResponse, ApiError> {
|
|
||||||
let agent_version = self
|
|
||||||
.get_agent_version(workspace_id, agent_id, version)
|
|
||||||
.await?;
|
|
||||||
validate_tool_selection_policy(
|
|
||||||
&agent_version.snapshot.tool_selection_policy,
|
|
||||||
&agent_version.bindings,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if agent_version
|
|
||||||
.bindings
|
|
||||||
.iter()
|
|
||||||
.filter(|binding| binding.enabled)
|
|
||||||
.count()
|
|
||||||
== 0
|
|
||||||
{
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"agent cannot be published without published enabled tools",
|
|
||||||
json!({
|
|
||||||
"agent_id": agent_id.as_str(),
|
|
||||||
"version": version,
|
|
||||||
"binding_count": agent_version.bindings.len()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let published_at = OffsetDateTime::now_utc();
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.publish_agent(PublishAgentRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
version,
|
|
||||||
published_at: &published_at,
|
|
||||||
published_by: None,
|
|
||||||
expected_state,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
info!(
|
|
||||||
name: "admin.agent.published",
|
|
||||||
agent_id = %agent_id.as_str(),
|
|
||||||
version,
|
|
||||||
"agent published"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(PublishAgentResponse {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
published_version: version,
|
|
||||||
published_at: format_timestamp(published_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn validate_exact_published_agent_bindings(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
bindings: &[AgentOperationBinding],
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
for binding in bindings {
|
|
||||||
let Some(summary) = self
|
|
||||||
.registry
|
|
||||||
.get_operation_summary(workspace_id, &binding.operation_id)
|
|
||||||
.await?
|
|
||||||
else {
|
|
||||||
return Err(ApiError::not_found_with_context(
|
|
||||||
"operation was not found for Agent binding",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_binding_scope_denied",
|
|
||||||
"operation_id": binding.operation_id.as_str()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if summary.status == crank_core::OperationStatus::Archived {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"archived operation cannot be added to an Agent binding",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_binding_archived_operation",
|
|
||||||
"operation_id": binding.operation_id.as_str()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(version) = self
|
|
||||||
.registry
|
|
||||||
.get_operation_version(
|
|
||||||
workspace_id,
|
|
||||||
&binding.operation_id,
|
|
||||||
binding.operation_version,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
else {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"operation version is not published for Agent binding",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_binding_not_published",
|
|
||||||
"operation_id": binding.operation_id.as_str(),
|
|
||||||
"operation_version": binding.operation_version
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if !version.snapshot.is_published() {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"operation version is not published for Agent binding",
|
|
||||||
json!({
|
|
||||||
"error_code": "agent_binding_not_published",
|
|
||||||
"operation_id": binding.operation_id.as_str(),
|
|
||||||
"operation_version": binding.operation_version
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
|
||||||
pub async fn unpublish_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<AgentMutationResult, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let updated_at = OffsetDateTime::now_utc();
|
|
||||||
self.registry
|
|
||||||
.unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
|
|
||||||
.await?;
|
|
||||||
info!(
|
|
||||||
name: "admin.agent.unpublished",
|
|
||||||
agent_id = %agent_id.as_str(),
|
|
||||||
"agent moved to draft"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(AgentMutationResult {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
updated_at: format_timestamp(updated_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
|
||||||
pub async fn archive_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
expected_state: Option<&AgentStateExpectation>,
|
|
||||||
) -> Result<AgentMutationResult, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let updated_at = OffsetDateTime::now_utc();
|
|
||||||
self.registry
|
|
||||||
.archive_agent(workspace_id, agent_id, &updated_at, expected_state)
|
|
||||||
.await?;
|
|
||||||
info!(
|
|
||||||
name: "admin.agent.archived",
|
|
||||||
agent_id = %agent_id.as_str(),
|
|
||||||
"agent archived"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(AgentMutationResult {
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
updated_at: format_timestamp(updated_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_tool_selection_policy(
|
|
||||||
policy: &ToolSelectionPolicy,
|
|
||||||
bindings: &[AgentOperationBinding],
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
policy
|
|
||||||
.validate_for_tools(
|
|
||||||
bindings
|
|
||||||
.iter()
|
|
||||||
.filter(|binding| binding.enabled)
|
|
||||||
.map(|binding| binding.tool_name.as_str()),
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
ApiError::validation_with_context(
|
|
||||||
error.to_string(),
|
|
||||||
json!({"field": "tool_selection_policy"}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
use crank_core::{
|
|
||||||
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
|
||||||
PlatformApiKeyStatus, WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
|
|
||||||
use serde_json::json;
|
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
||||||
use tracing::instrument;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
|
|
||||||
EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
|
|
||||||
generate_access_secret, hash_access_secret, new_prefixed_id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn list_agent_platform_api_keys(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
self.registry
|
|
||||||
.get_agent_summary(workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(self
|
|
||||||
.registry
|
|
||||||
.list_platform_api_keys_for_agent(workspace_id, agent_id)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
|
|
||||||
pub async fn create_agent_platform_api_key(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
mut payload: PlatformApiKeyPayload,
|
|
||||||
audit_context: Option<&AdminAuditContext>,
|
|
||||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
|
||||||
let workspace = match self.get_workspace(workspace_id).await {
|
|
||||||
Ok(workspace) => workspace,
|
|
||||||
Err(error) => {
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.create_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: "pending",
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let agent_result = match self
|
|
||||||
.registry
|
|
||||||
.get_agent_summary(workspace_id, agent_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(agent) => agent.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
Err(error) => Err(ApiError::from(error)),
|
|
||||||
};
|
|
||||||
let agent = match agent_result {
|
|
||||||
Ok(agent) => agent,
|
|
||||||
Err(error) => {
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.create_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: "pending",
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let expires_at = match validate_platform_api_key_payload(&mut payload) {
|
|
||||||
Ok(expires_at) => expires_at,
|
|
||||||
Err(error) => {
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.create_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: "pending",
|
|
||||||
credential_type: payload.key_kind.audit_credential_type(),
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let secret = generate_access_secret(payload.key_kind.secret_marker());
|
|
||||||
let api_key = PlatformApiKeyRecord {
|
|
||||||
api_key: PlatformApiKey {
|
|
||||||
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
|
|
||||||
workspace_id: workspace_id.clone(),
|
|
||||||
agent_id: Some(agent_id.clone()),
|
|
||||||
name: payload.name,
|
|
||||||
prefix: secret.chars().take(16).collect(),
|
|
||||||
key_kind: payload.key_kind,
|
|
||||||
scopes: payload.scopes,
|
|
||||||
status: PlatformApiKeyStatus::Active,
|
|
||||||
created_at: OffsetDateTime::now_utc(),
|
|
||||||
last_used_at: None,
|
|
||||||
expires_at,
|
|
||||||
allowed_origins: payload.allowed_origins,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(error) = self
|
|
||||||
.registry
|
|
||||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
|
||||||
api_key: &api_key.api_key,
|
|
||||||
secret_hash: &hash_access_secret(&secret),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let error = ApiError::from(error);
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.create_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: api_key.api_key.id.as_str(),
|
|
||||||
credential_type: api_key.api_key.key_kind.audit_credential_type(),
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.created",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: api_key.api_key.id.as_str(),
|
|
||||||
credential_type: api_key.api_key.key_kind.audit_credential_type(),
|
|
||||||
outcome: "success",
|
|
||||||
reason: "credential_created",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let connection = (api_key.api_key.key_kind == PlatformApiKeyKind::McpClient).then(|| {
|
|
||||||
let endpoint = self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug);
|
|
||||||
EphemeralMcpConnection {
|
|
||||||
endpoint: endpoint.clone(),
|
|
||||||
clients: ephemeral_client_configs(&endpoint, &secret),
|
|
||||||
secret_display: "once",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(CreatedPlatformApiKeyResponse {
|
|
||||||
api_key,
|
|
||||||
secret,
|
|
||||||
connection,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
|
||||||
pub async fn revoke_agent_platform_api_key(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
key_id: &PlatformApiKeyId,
|
|
||||||
audit_context: Option<&AdminAuditContext>,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if let Err(error) = self
|
|
||||||
.registry
|
|
||||||
.revoke_platform_api_key_for_agent(
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
key_id,
|
|
||||||
&OffsetDateTime::now_utc(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let error = ApiError::from(error);
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.revoke_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: key_id.as_str(),
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.revoked",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: key_id.as_str(),
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "success",
|
|
||||||
reason: "credential_revoked",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
|
||||||
pub async fn delete_agent_platform_api_key(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
key_id: &PlatformApiKeyId,
|
|
||||||
audit_context: Option<&AdminAuditContext>,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if let Err(error) = self
|
|
||||||
.registry
|
|
||||||
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let error = ApiError::from(error);
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.delete_failed",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: key_id.as_str(),
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "failure",
|
|
||||||
reason: error.code(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
self.record_credential_audit(
|
|
||||||
audit_context,
|
|
||||||
CredentialAuditRecord {
|
|
||||||
action: "credential.platform_api_key.deleted",
|
|
||||||
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
|
|
||||||
workspace_id,
|
|
||||||
target_id: key_id.as_str(),
|
|
||||||
credential_type: "platform_api_key",
|
|
||||||
outcome: "success",
|
|
||||||
reason: "credential_deleted",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec<EphemeralMcpClientConfig> {
|
|
||||||
["claude_desktop", "cursor", "vscode"]
|
|
||||||
.into_iter()
|
|
||||||
.map(|client| EphemeralMcpClientConfig {
|
|
||||||
client: client.to_owned(),
|
|
||||||
config: json!({
|
|
||||||
"transport": "streamable_http",
|
|
||||||
"url": endpoint,
|
|
||||||
"headers": {"Authorization": format!("Bearer {secret}")}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
trait PlatformApiKeyKindAuditExt {
|
|
||||||
fn audit_credential_type(self) -> &'static str;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlatformApiKeyKindAuditExt for PlatformApiKeyKind {
|
|
||||||
fn audit_credential_type(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
PlatformApiKeyKind::McpClient => "mcp_client_key",
|
|
||||||
PlatformApiKeyKind::Approval => "approval_key",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_platform_api_key_payload(
|
|
||||||
payload: &mut PlatformApiKeyPayload,
|
|
||||||
) -> Result<Option<OffsetDateTime>, ApiError> {
|
|
||||||
const MAX_KEY_NAME_CHARS: usize = 128;
|
|
||||||
const MAX_SCOPES: usize = 8;
|
|
||||||
|
|
||||||
payload.name = payload.name.trim().to_owned();
|
|
||||||
if payload.name.trim().is_empty() {
|
|
||||||
return Err(ApiError::validation("key name is required"));
|
|
||||||
}
|
|
||||||
if payload.name.chars().count() > MAX_KEY_NAME_CHARS {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"key name must be at most 128 characters",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if payload.scopes.is_empty() {
|
|
||||||
return Err(ApiError::validation("at least one key scope is required"));
|
|
||||||
}
|
|
||||||
if payload.scopes.len() > MAX_SCOPES {
|
|
||||||
return Err(ApiError::validation("too many key scopes"));
|
|
||||||
}
|
|
||||||
if payload
|
|
||||||
.scopes
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.any(|(index, scope)| payload.scopes[..index].contains(scope))
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"key scopes must not contain duplicates",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
|
|
||||||
PlatformApiKeyKind::McpClient => matches!(
|
|
||||||
scope,
|
|
||||||
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy
|
|
||||||
),
|
|
||||||
PlatformApiKeyKind::Approval => matches!(
|
|
||||||
scope,
|
|
||||||
PlatformApiKeyScope::Approve
|
|
||||||
| PlatformApiKeyScope::Deny
|
|
||||||
| PlatformApiKeyScope::ReadPending
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if !valid {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"key scopes do not match selected key kind",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"approval key can contain at most 20 allowed origins",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if payload.key_kind == PlatformApiKeyKind::Approval {
|
|
||||||
let mut normalized_origins = Vec::with_capacity(payload.allowed_origins.len());
|
|
||||||
for origin in &payload.allowed_origins {
|
|
||||||
normalized_origins.push(validate_approval_origin(origin)?);
|
|
||||||
}
|
|
||||||
if normalized_origins
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.any(|(index, origin)| normalized_origins[..index].contains(origin))
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"allowed origins must not contain duplicates",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
payload.allowed_origins = normalized_origins;
|
|
||||||
} else if !payload.allowed_origins.is_empty() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"allowed origins are only supported for approval keys",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expires_at = payload
|
|
||||||
.expires_at
|
|
||||||
.as_deref()
|
|
||||||
.map(|value| {
|
|
||||||
OffsetDateTime::parse(value, &Rfc3339)
|
|
||||||
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))
|
|
||||||
})
|
|
||||||
.transpose()?;
|
|
||||||
if expires_at.is_some_and(|value| value <= OffsetDateTime::now_utc()) {
|
|
||||||
return Err(ApiError::validation("expires_at must be in the future"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(expires_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_approval_origin(origin: &str) -> Result<String, ApiError> {
|
|
||||||
const MAX_ORIGIN_LEN: usize = 2048;
|
|
||||||
if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN {
|
|
||||||
return Err(ApiError::validation("allowed origin is invalid"));
|
|
||||||
}
|
|
||||||
if origin
|
|
||||||
.bytes()
|
|
||||||
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation("allowed origin is invalid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed =
|
|
||||||
Url::parse(origin).map_err(|_| ApiError::validation("allowed origin is invalid"))?;
|
|
||||||
if !matches!(parsed.scheme(), "http" | "https")
|
|
||||||
|| parsed.host_str().is_none()
|
|
||||||
|| !parsed.username().is_empty()
|
|
||||||
|| parsed.password().is_some()
|
|
||||||
|| parsed.path() != "/"
|
|
||||||
|| parsed.query().is_some()
|
|
||||||
|| parsed.fragment().is_some()
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation("allowed origin is invalid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parsed.origin().ascii_serialization())
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
use crank_core::{
|
|
||||||
LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest};
|
|
||||||
use serde_json::json;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::{
|
|
||||||
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
|
|
||||||
hash_csrf_token, hash_password, hash_session_secret, verify_password,
|
|
||||||
},
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
|
|
||||||
LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
|
|
||||||
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
|
|
||||||
Ok(BootstrapStatusResponse {
|
|
||||||
bootstrap_required: !self.registry.has_password_admin().await?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn complete_bootstrap(
|
|
||||||
&self,
|
|
||||||
payload: CompleteBootstrapPayload,
|
|
||||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
||||||
if payload.token.len() < 32 || payload.token.len() > 256 {
|
|
||||||
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
||||||
.await;
|
|
||||||
return Err(Self::constant_bootstrap_error());
|
|
||||||
}
|
|
||||||
if payload.password.len() < 12 || payload.password.len() > 256 {
|
|
||||||
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
||||||
.await;
|
|
||||||
return Err(Self::constant_bootstrap_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token));
|
|
||||||
if !self
|
|
||||||
.registry
|
|
||||||
.admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
||||||
.await;
|
|
||||||
return Err(Self::constant_bootstrap_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?;
|
|
||||||
let user_id = self
|
|
||||||
.registry
|
|
||||||
.consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest {
|
|
||||||
token_hash: &bootstrap_token_hash,
|
|
||||||
password_hash: &password_hash,
|
|
||||||
now: &now,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| Self::constant_bootstrap_error());
|
|
||||||
let user_id = match user_id {
|
|
||||||
Ok(user_id) => user_id,
|
|
||||||
Err(error) => {
|
|
||||||
self.record_admin_security_audit(
|
|
||||||
None,
|
|
||||||
"bootstrap_rejected",
|
|
||||||
"rejected",
|
|
||||||
"bootstrap",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.record_admin_security_audit(
|
|
||||||
Some(&user_id),
|
|
||||||
"bootstrap_completed",
|
|
||||||
"success",
|
|
||||||
"bootstrap",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let user = self
|
|
||||||
.registry
|
|
||||||
.get_auth_user_by_id(&user_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::internal("bootstrap user was not found"))?
|
|
||||||
.user;
|
|
||||||
self.create_session_for_user(user).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_bootstrap_error() -> ApiError {
|
|
||||||
ApiError::unauthorized("bootstrap request is invalid or expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
|
|
||||||
let password_hash = hash_password(
|
|
||||||
&self.auth_settings.bootstrap_admin.password,
|
|
||||||
&self.auth_settings.password_pepper,
|
|
||||||
)?;
|
|
||||||
let user_id = self
|
|
||||||
.registry
|
|
||||||
.ensure_bootstrap_user(
|
|
||||||
&self.auth_settings.bootstrap_admin.email,
|
|
||||||
&self.auth_settings.bootstrap_admin.display_name,
|
|
||||||
&password_hash,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
self.registry
|
|
||||||
.ensure_membership(
|
|
||||||
&WorkspaceId::new("ws_default"),
|
|
||||||
&user_id,
|
|
||||||
MembershipRole::Owner,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn seed_demo_assets(&self) -> Result<(), ApiError> {
|
|
||||||
let admin_user = self
|
|
||||||
.registry
|
|
||||||
.get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?;
|
|
||||||
let admin_user_id = admin_user.user.id.clone();
|
|
||||||
let default_workspace_id = WorkspaceId::new("ws_default");
|
|
||||||
|
|
||||||
self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_session(
|
|
||||||
&self,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
session_value: &str,
|
|
||||||
) -> Result<Option<AuthenticatedSession>, ApiError> {
|
|
||||||
let secret_hash = hash_session_secret(
|
|
||||||
session_id,
|
|
||||||
session_value,
|
|
||||||
&self.auth_settings.session_secret,
|
|
||||||
);
|
|
||||||
let session = self
|
|
||||||
.registry
|
|
||||||
.get_user_session(session_id, &secret_hash)
|
|
||||||
.await?
|
|
||||||
.map(|record| AuthenticatedSession {
|
|
||||||
session_id: record.session_id,
|
|
||||||
user: record.user,
|
|
||||||
memberships: record.memberships,
|
|
||||||
current_workspace_id: record.current_workspace_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> {
|
|
||||||
self.registry.touch_user_session(session_id).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn verify_session_csrf(
|
|
||||||
&self,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
csrf_hash: &str,
|
|
||||||
) -> Result<bool, ApiError> {
|
|
||||||
Ok(self
|
|
||||||
.registry
|
|
||||||
.verify_session_csrf(session_id, csrf_hash)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
&self,
|
|
||||||
payload: LoginPayload,
|
|
||||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
||||||
self.login_with_client_bucket(payload, "anonymous").await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login_with_client_bucket(
|
|
||||||
&self,
|
|
||||||
payload: LoginPayload,
|
|
||||||
client_bucket: &str,
|
|
||||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
||||||
let scope_hash = hash_access_secret(&format!(
|
|
||||||
"login:{}:{}",
|
|
||||||
client_bucket,
|
|
||||||
payload.email.trim().to_ascii_lowercase()
|
|
||||||
));
|
|
||||||
if let Some(locked_until) = self
|
|
||||||
.registry
|
|
||||||
.login_backoff_locked_until(&scope_hash)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
let retry_after_ms = (locked_until - OffsetDateTime::now_utc())
|
|
||||||
.whole_milliseconds()
|
|
||||||
.clamp(1, 300_000);
|
|
||||||
return Err(ApiError::rate_limited_with_context(
|
|
||||||
"login temporarily unavailable",
|
|
||||||
json!({
|
|
||||||
"retry_after_ms": retry_after_ms,
|
|
||||||
"error_code": "login_throttled",
|
|
||||||
"recovery": "retry_after_delay"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let authenticated = match self.authenticate_login(&payload).await {
|
|
||||||
Ok(authenticated) => authenticated,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = self
|
|
||||||
.registry
|
|
||||||
.record_login_failure(&scope_hash, &OffsetDateTime::now_utc())
|
|
||||||
.await;
|
|
||||||
self.record_admin_security_audit(None, "login_rejected", "rejected", "login")
|
|
||||||
.await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.registry.reset_login_backoff(&scope_hash).await?;
|
|
||||||
self.record_admin_security_audit(
|
|
||||||
Some(&authenticated.user.id),
|
|
||||||
"login_succeeded",
|
|
||||||
"success",
|
|
||||||
"login",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
self.create_session_for_user(authenticated.user).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_session_for_user(
|
|
||||||
&self,
|
|
||||||
user: User,
|
|
||||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
||||||
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
|
||||||
let csrf_token = create_csrf_token_value();
|
|
||||||
let secret_hash = hash_session_secret(
|
|
||||||
&session_cookie.session_id,
|
|
||||||
&session_cookie.value,
|
|
||||||
&self.auth_settings.session_secret,
|
|
||||||
);
|
|
||||||
let csrf_hash = hash_csrf_token(
|
|
||||||
&session_cookie.session_id,
|
|
||||||
&csrf_token,
|
|
||||||
&self.auth_settings.session_secret,
|
|
||||||
);
|
|
||||||
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
|
|
||||||
let default_workspace_id = memberships
|
|
||||||
.iter()
|
|
||||||
.find(|membership| membership.workspace.id.as_str() == "ws_default")
|
|
||||||
.map(|membership| membership.workspace.id.as_str().to_owned());
|
|
||||||
let current_workspace_id = default_workspace_id.or_else(|| {
|
|
||||||
memberships
|
|
||||||
.first()
|
|
||||||
.map(|membership| membership.workspace.id.as_str().to_owned())
|
|
||||||
});
|
|
||||||
let current_workspace_ref = current_workspace_id
|
|
||||||
.as_ref()
|
|
||||||
.map(|workspace_id| WorkspaceId::new(workspace_id.clone()));
|
|
||||||
self.registry
|
|
||||||
.create_user_session(
|
|
||||||
&session_cookie.session_id,
|
|
||||||
&user.id,
|
|
||||||
current_workspace_ref.as_ref(),
|
|
||||||
&secret_hash,
|
|
||||||
Some(&csrf_hash),
|
|
||||||
&session_cookie.expires_at,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
session_cookie,
|
|
||||||
SessionResponse {
|
|
||||||
user,
|
|
||||||
memberships,
|
|
||||||
current_workspace_id,
|
|
||||||
csrf_token,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn authenticate_login(
|
|
||||||
&self,
|
|
||||||
payload: &LoginPayload,
|
|
||||||
) -> Result<crank_core::AuthenticatedIdentity, ApiError> {
|
|
||||||
if let Some(identity_provider) = &self.identity_provider {
|
|
||||||
return match identity_provider
|
|
||||||
.login_password(crank_core::LoginPayload {
|
|
||||||
email: payload.email.clone(),
|
|
||||||
password: payload.password.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(LoginOutcome::Authenticated(identity)) => Ok(identity),
|
|
||||||
Err(error) => Err(map_identity_error(error)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = self
|
|
||||||
.registry
|
|
||||||
.get_auth_user_by_email(&payload.email)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
let _ = hash_password(&payload.password, &self.auth_settings.password_pepper);
|
|
||||||
ApiError::unauthorized("invalid email or password")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if user.user.status != UserStatus::Active {
|
|
||||||
let _ = verify_password(
|
|
||||||
&payload.password,
|
|
||||||
&self.auth_settings.password_pepper,
|
|
||||||
&user.password_hash,
|
|
||||||
);
|
|
||||||
return Err(ApiError::unauthorized("invalid email or password"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !verify_password(
|
|
||||||
&payload.password,
|
|
||||||
&self.auth_settings.password_pepper,
|
|
||||||
&user.password_hash,
|
|
||||||
) {
|
|
||||||
return Err(ApiError::unauthorized("invalid email or password"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(crank_core::AuthenticatedIdentity {
|
|
||||||
user: user.user,
|
|
||||||
memberships: vec![],
|
|
||||||
current_workspace_id: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn logout(
|
|
||||||
&self,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
_session_value: &str,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
self.registry.revoke_user_session(session_id).await?;
|
|
||||||
self.record_admin_security_audit(None, "logout", "success", "session")
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn session_response(
|
|
||||||
&self,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
session_value: &str,
|
|
||||||
) -> Result<Option<SessionResponse>, ApiError> {
|
|
||||||
let Some(session) = self.get_session(session_id, session_value).await? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
Ok(Some(SessionResponse {
|
|
||||||
user: session.user,
|
|
||||||
memberships: session.memberships,
|
|
||||||
current_workspace_id: session
|
|
||||||
.current_workspace_id
|
|
||||||
.map(|id| id.as_str().to_owned()),
|
|
||||||
csrf_token: String::new(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_profile(
|
|
||||||
&self,
|
|
||||||
user_id: &crank_core::UserId,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
current_workspace_id: Option<&WorkspaceId>,
|
|
||||||
payload: UpdateProfilePayload,
|
|
||||||
) -> Result<SessionResponse, ApiError> {
|
|
||||||
let display_name = validate_profile_display_name(&payload.display_name)?;
|
|
||||||
let email = validate_profile_email(&payload.email)?;
|
|
||||||
|
|
||||||
let user = self
|
|
||||||
.registry
|
|
||||||
.update_user_profile(user_id, &email, &display_name)
|
|
||||||
.await?;
|
|
||||||
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
|
|
||||||
|
|
||||||
Ok(SessionResponse {
|
|
||||||
user,
|
|
||||||
memberships,
|
|
||||||
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
|
|
||||||
csrf_token: self.rotate_session_csrf_token(session_id).await?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn change_password(
|
|
||||||
&self,
|
|
||||||
user_id: &crank_core::UserId,
|
|
||||||
_current_session_id: &UserSessionId,
|
|
||||||
payload: ChangePasswordPayload,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"new password must be between 12 and 256 characters long",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = self
|
|
||||||
.registry
|
|
||||||
.get_auth_user_by_id(user_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("user {} was not found", user_id.as_str()),
|
|
||||||
json!({ "user_id": user_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !verify_password(
|
|
||||||
&payload.current_password,
|
|
||||||
&self.auth_settings.password_pepper,
|
|
||||||
&user.password_hash,
|
|
||||||
) {
|
|
||||||
return Err(ApiError::unauthorized("current password is invalid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash =
|
|
||||||
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
|
|
||||||
self.registry
|
|
||||||
.update_user_password_and_revoke_all_sessions(user_id, &password_hash)
|
|
||||||
.await?;
|
|
||||||
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_admin_security_audit(
|
|
||||||
&self,
|
|
||||||
actor_user_id: Option<&UserId>,
|
|
||||||
action: &'static str,
|
|
||||||
outcome: &'static str,
|
|
||||||
source: &'static str,
|
|
||||||
) {
|
|
||||||
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
|
||||||
let audit_id = new_prefixed_id("audit");
|
|
||||||
let _ = self
|
|
||||||
.registry
|
|
||||||
.record_admin_security_audit(AdminSecurityAuditRequest {
|
|
||||||
id: &audit_id,
|
|
||||||
action,
|
|
||||||
outcome,
|
|
||||||
actor_user_id,
|
|
||||||
session_id: None,
|
|
||||||
request_id: request_id.as_deref(),
|
|
||||||
trace_id: trace_id.as_deref(),
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn rotate_session_csrf_token(
|
|
||||||
&self,
|
|
||||||
session_id: &UserSessionId,
|
|
||||||
) -> Result<String, ApiError> {
|
|
||||||
let csrf_token = create_csrf_token_value();
|
|
||||||
let csrf_hash =
|
|
||||||
hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret);
|
|
||||||
self.registry
|
|
||||||
.update_user_session_csrf(session_id, &csrf_hash)
|
|
||||||
.await?;
|
|
||||||
Ok(csrf_token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_core::{
|
|
||||||
AgentId, AgentOperationBinding, InvocationLevel, InvocationSource, InvocationStatus,
|
|
||||||
MembershipRole, OperationId, OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope,
|
|
||||||
PlatformApiKeyStatus, Protocol, Target, WizardState, WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
|
||||||
use crank_registry::{ListInvocationLogsQuery, OperationSummary, RegistryError, SampleKind};
|
|
||||||
use crank_schema::Schema;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, AgentBindingPayload, AgentPayload, AgentSummaryView, InvocationRecordRequest,
|
|
||||||
OperationPayload, PlatformApiKeyPayload,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub(super) async fn seed_default_workspace_demo(
|
|
||||||
&self,
|
|
||||||
owner_user_id: &crank_core::UserId,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
self.registry
|
|
||||||
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.cleanup_legacy_demo_assets(workspace_id).await?;
|
|
||||||
|
|
||||||
let rest_operation = self
|
|
||||||
.ensure_demo_operation(workspace_id, demo_rest_operation_payload())
|
|
||||||
.await?;
|
|
||||||
self.ensure_operation_published(workspace_id, &rest_operation)
|
|
||||||
.await?;
|
|
||||||
self.ensure_demo_json_samples(
|
|
||||||
workspace_id,
|
|
||||||
&rest_operation.id,
|
|
||||||
&demo_rest_input_sample(),
|
|
||||||
&demo_rest_output_sample(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let currency_agent = self
|
|
||||||
.ensure_demo_agent(workspace_id, demo_currency_agent_payload())
|
|
||||||
.await?;
|
|
||||||
self.ensure_demo_agent_bindings(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(currency_agent.id.clone()),
|
|
||||||
vec![AgentBindingPayload {
|
|
||||||
operation_id: rest_operation.id.as_str().to_owned(),
|
|
||||||
operation_version: rest_operation.current_draft_version,
|
|
||||||
tool_name: "frankfurter_latest_rate".to_owned(),
|
|
||||||
tool_title: "Последний курс валюты".to_owned(),
|
|
||||||
tool_description_override: Some(
|
|
||||||
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
|
||||||
.to_owned(),
|
|
||||||
),
|
|
||||||
enabled: true,
|
|
||||||
}],
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.ensure_demo_platform_api_key(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(currency_agent.id.clone()),
|
|
||||||
"Frankfurter Demo Key",
|
|
||||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.seed_demo_invocation_logs(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(currency_agent.id),
|
|
||||||
&rest_operation.id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
|
|
||||||
for slug in ["revops-copilot", "support-triage"] {
|
|
||||||
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? {
|
|
||||||
self.delete_agent(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(agent.id.as_str().to_owned()),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let operations = self.registry.list_operations(workspace_id).await?;
|
|
||||||
for operation in operations {
|
|
||||||
if operation.name.starts_with("internal_health_smoke_")
|
|
||||||
|| operation
|
|
||||||
.name
|
|
||||||
.starts_with("weather_current_open_meteo_smoke_")
|
|
||||||
{
|
|
||||||
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for name in [
|
|
||||||
"crm_create_lead",
|
|
||||||
"marketing_archive_contact",
|
|
||||||
"weather_current_open_meteo",
|
|
||||||
] {
|
|
||||||
if let Some(operation) = self.find_operation_by_name(workspace_id, name).await? {
|
|
||||||
self.delete_legacy_demo_operation_if_safe(workspace_id, &operation.id)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_legacy_demo_operation_if_safe(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
operation_id: &OperationId,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
match self
|
|
||||||
.registry
|
|
||||||
.delete_operation(workspace_id, operation_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(
|
|
||||||
RegistryError::OperationHasPublishedAgentBindings { .. }
|
|
||||||
| RegistryError::OperationDeleteForbidden { .. },
|
|
||||||
) => {
|
|
||||||
tracing::warn!(
|
|
||||||
name: "admin.demo_operation.cleanup_skipped",
|
|
||||||
operation_id = %operation_id.as_str(),
|
|
||||||
"legacy demo operation is still bound to a published agent; leaving it in place"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) => Err(ApiError::from(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_platform_api_key(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
name: &str,
|
|
||||||
scopes: Vec<PlatformApiKeyScope>,
|
|
||||||
revoke: bool,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let existing = self
|
|
||||||
.registry
|
|
||||||
.list_platform_api_keys(workspace_id)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.find(|record| record.api_key.name == name);
|
|
||||||
let key = match existing {
|
|
||||||
Some(record) => record,
|
|
||||||
None => {
|
|
||||||
self.create_agent_platform_api_key(
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
PlatformApiKeyPayload {
|
|
||||||
name: name.to_owned(),
|
|
||||||
key_kind: PlatformApiKeyKind::McpClient,
|
|
||||||
scopes,
|
|
||||||
expires_at: None,
|
|
||||||
allowed_origins: Vec::new(),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.api_key
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
|
||||||
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id, None)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_operation(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: OperationPayload,
|
|
||||||
) -> Result<OperationSummary, ApiError> {
|
|
||||||
if let Some(existing) = self
|
|
||||||
.find_operation_by_name(workspace_id, &payload.name)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Ok(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let operation_name = payload.name.clone();
|
|
||||||
self.create_operation(workspace_id, payload).await?;
|
|
||||||
self.find_operation_by_name(workspace_id, &operation_name)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::internal("demo operation was created but not found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_operation_published(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
summary: &OperationSummary,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if summary.latest_published_version.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.publish_operation(
|
|
||||||
workspace_id,
|
|
||||||
&summary.id,
|
|
||||||
summary.current_draft_version,
|
|
||||||
&crank_registry::OperationStateExpectation {
|
|
||||||
current_draft_version: summary.current_draft_version,
|
|
||||||
status: summary.status,
|
|
||||||
latest_published_version: summary.latest_published_version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_json_samples(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
operation_id: &OperationId,
|
|
||||||
input: &Value,
|
|
||||||
output: &Value,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
|
||||||
let samples = self
|
|
||||||
.registry
|
|
||||||
.list_sample_metadata(operation_id, summary.current_draft_version)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !samples
|
|
||||||
.iter()
|
|
||||||
.any(|sample| sample.sample_kind == SampleKind::InputJson)
|
|
||||||
{
|
|
||||||
self.save_json_sample(workspace_id, operation_id, SampleKind::InputJson, input)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !samples
|
|
||||||
.iter()
|
|
||||||
.any(|sample| sample.sample_kind == SampleKind::OutputJson)
|
|
||||||
{
|
|
||||||
self.save_json_sample(workspace_id, operation_id, SampleKind::OutputJson, output)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_agent(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: AgentPayload,
|
|
||||||
) -> Result<AgentSummaryView, ApiError> {
|
|
||||||
let summary =
|
|
||||||
if let Some(existing) = self.find_agent_by_slug(workspace_id, &payload.slug).await? {
|
|
||||||
existing
|
|
||||||
} else {
|
|
||||||
self.create_agent(workspace_id, payload.clone()).await?;
|
|
||||||
self.find_agent_by_slug(workspace_id, &payload.slug)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::internal("demo agent was created but not found"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
self.get_agent(workspace_id, &summary.id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_agent_bindings(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
bindings: Vec<AgentBindingPayload>,
|
|
||||||
publish: bool,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let summary = self.get_agent(workspace_id, agent_id).await?;
|
|
||||||
let current = self
|
|
||||||
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
|
|
||||||
.await?;
|
|
||||||
if !demo_agent_bindings_match(¤t.bindings, &bindings) {
|
|
||||||
self.save_agent_bindings(workspace_id, agent_id, bindings.into(), None)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
if publish && summary.latest_published_version.is_none() {
|
|
||||||
self.publish_agent(workspace_id, agent_id, summary.current_draft_version, None)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_demo_invocation_logs(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
currency_agent_id: &AgentId,
|
|
||||||
rest_operation_id: &OperationId,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if !self
|
|
||||||
.registry
|
|
||||||
.list_invocation_logs(ListInvocationLogsQuery {
|
|
||||||
workspace_id,
|
|
||||||
level: None,
|
|
||||||
status: None,
|
|
||||||
outcome_group: None,
|
|
||||||
search_text: None,
|
|
||||||
source: None,
|
|
||||||
operation_id: None,
|
|
||||||
agent_id: None,
|
|
||||||
created_after: None,
|
|
||||||
created_before: None,
|
|
||||||
cursor_created_at: None,
|
|
||||||
cursor_id: None,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rest_operation = self
|
|
||||||
.get_operation_version(
|
|
||||||
workspace_id,
|
|
||||||
rest_operation_id,
|
|
||||||
self.get_operation(workspace_id, rest_operation_id)
|
|
||||||
.await?
|
|
||||||
.current_draft_version,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let correlation = crank_core::CorrelationContext::generate();
|
|
||||||
self.record_invocation(InvocationRecordRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id: Some(currency_agent_id),
|
|
||||||
operation: &rest_operation.snapshot,
|
|
||||||
request_id: Some(correlation.request_id().as_str()),
|
|
||||||
trace_id: Some(correlation.trace_id().as_str()),
|
|
||||||
source: InvocationSource::AgentToolCall,
|
|
||||||
level: InvocationLevel::Info,
|
|
||||||
status: InvocationStatus::Ok,
|
|
||||||
message: "Frankfurter returned latest exchange rate".to_owned(),
|
|
||||||
status_code: Some(200),
|
|
||||||
error_kind: None,
|
|
||||||
execution_stage: Some(crank_core::ExecutionStage::Runtime),
|
|
||||||
execution_error_code: None,
|
|
||||||
retryability: Some(crank_core::Retryability::Never),
|
|
||||||
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
|
|
||||||
duration_ms: 124,
|
|
||||||
request_preview: json!({
|
|
||||||
"path": {},
|
|
||||||
"query": demo_rest_request_sample(),
|
|
||||||
"headers": { "Accept": "application/json" },
|
|
||||||
"body": null
|
|
||||||
}),
|
|
||||||
response_preview: demo_rest_response_sample(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_agent_bindings_match(
|
|
||||||
existing: &[AgentOperationBinding],
|
|
||||||
desired: &[AgentBindingPayload],
|
|
||||||
) -> bool {
|
|
||||||
if existing.len() != desired.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut desired = desired.iter().collect::<Vec<_>>();
|
|
||||||
desired.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
|
|
||||||
existing.iter().zip(desired).all(|(existing, desired)| {
|
|
||||||
existing.operation_id.as_str() == desired.operation_id
|
|
||||||
&& existing.operation_version == desired.operation_version
|
|
||||||
&& existing.tool_name == desired.tool_name
|
|
||||||
&& existing.tool_title == desired.tool_title
|
|
||||||
&& existing.tool_description_override == desired.tool_description_override
|
|
||||||
&& existing.enabled == desired.enabled
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_currency_agent_payload() -> AgentPayload {
|
|
||||||
AgentPayload {
|
|
||||||
slug: "currency-rates".to_owned(),
|
|
||||||
display_name: "Курсы валют".to_owned(),
|
|
||||||
description: "Агент с инструментами для получения курсов валют.".to_owned(),
|
|
||||||
instructions: json!({
|
|
||||||
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
|
||||||
}),
|
|
||||||
tool_selection_policy: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_operation_payload() -> OperationPayload {
|
|
||||||
let input = demo_rest_input_sample();
|
|
||||||
let response = demo_rest_response_sample();
|
|
||||||
let output = demo_rest_output_sample();
|
|
||||||
|
|
||||||
OperationPayload {
|
|
||||||
name: "frankfurter_latest_rate".to_owned(),
|
|
||||||
display_name: "Последний курс валюты".to_owned(),
|
|
||||||
category: "frankfurter_rates".to_owned(),
|
|
||||||
protocol: Protocol::Rest,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
target: Target::Rest(crank_core::RestTarget {
|
|
||||||
base_url: "https://api.frankfurter.dev".to_owned(),
|
|
||||||
method: crank_core::HttpMethod::Get,
|
|
||||||
path_template: "/v1/latest".to_owned(),
|
|
||||||
static_headers: BTreeMap::from([("Accept".to_owned(), "application/json".to_owned())]),
|
|
||||||
}),
|
|
||||||
input_schema: Schema::from_json_sample(&input),
|
|
||||||
output_schema: Schema::from_json_sample(&output),
|
|
||||||
input_mapping: frankfurter_input_mapping(),
|
|
||||||
output_mapping: infer_mapping_from_samples(
|
|
||||||
&response,
|
|
||||||
JsonPathRoot::ResponseBody,
|
|
||||||
&output,
|
|
||||||
JsonPathRoot::Output,
|
|
||||||
),
|
|
||||||
execution_config: crank_core::ExecutionConfig {
|
|
||||||
timeout_ms: 10_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
approval_policy: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
tool_description: crank_core::ToolDescription {
|
|
||||||
title: "Последний курс валюты".to_owned(),
|
|
||||||
description:
|
|
||||||
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
|
||||||
.to_owned(),
|
|
||||||
tags: vec![
|
|
||||||
"frankfurter".to_owned(),
|
|
||||||
"currency".to_owned(),
|
|
||||||
"exchange-rate".to_owned(),
|
|
||||||
],
|
|
||||||
examples: vec![crank_core::ToolExample {
|
|
||||||
input: json!({
|
|
||||||
"base": "USD",
|
|
||||||
"quote": "EUR"
|
|
||||||
}),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
wizard_state: Some(WizardState {
|
|
||||||
input_sample: Some(input),
|
|
||||||
output_sample: Some(output),
|
|
||||||
test_input: Some(demo_rest_input_sample()),
|
|
||||||
import_findings: Vec::new(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_input_sample() -> Value {
|
|
||||||
json!({
|
|
||||||
"base": "USD",
|
|
||||||
"quote": "EUR"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_request_sample() -> Value {
|
|
||||||
json!({
|
|
||||||
"base": "USD",
|
|
||||||
"symbols": "EUR"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_response_sample() -> Value {
|
|
||||||
json!({
|
|
||||||
"amount": 1.0,
|
|
||||||
"base": "USD",
|
|
||||||
"date": "2026-06-19",
|
|
||||||
"rates": {
|
|
||||||
"EUR": 0.87207
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_output_sample() -> Value {
|
|
||||||
demo_rest_response_sample()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frankfurter_input_mapping() -> MappingSet {
|
|
||||||
MappingSet {
|
|
||||||
rules: vec![
|
|
||||||
MappingRule {
|
|
||||||
source: "$.mcp.base".to_owned(),
|
|
||||||
target: "$.request.query.base".to_owned(),
|
|
||||||
required: true,
|
|
||||||
default_value: None,
|
|
||||||
transform: None,
|
|
||||||
condition: None,
|
|
||||||
notes: None,
|
|
||||||
},
|
|
||||||
MappingRule {
|
|
||||||
source: "$.mcp.quote".to_owned(),
|
|
||||||
target: "$.request.query.symbols".to_owned(),
|
|
||||||
required: true,
|
|
||||||
default_value: None,
|
|
||||||
transform: None,
|
|
||||||
condition: None,
|
|
||||||
notes: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
use crank_core::{OperationStatus, Target, WorkspaceId};
|
|
||||||
use crank_registry::OperationStateExpectation;
|
|
||||||
use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
|
|
||||||
use serde_yaml::Value as YamlValue;
|
|
||||||
use std::fmt;
|
|
||||||
use tracing::{info, instrument};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
import_guidance::import_guidance_warnings,
|
|
||||||
service::{
|
|
||||||
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse,
|
|
||||||
LegacyYamlOperationDocument, NewVersionPayload, OperationPayload, PortableOperation,
|
|
||||||
YamlOperationDocument,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_YAML_BYTES: usize = 256 * 1024;
|
|
||||||
const MAX_YAML_LINE_OR_SCALAR_BYTES: usize = 64 * 1024;
|
|
||||||
const MAX_YAML_DEPTH: usize = 64;
|
|
||||||
const MAX_YAML_NODES: usize = 20_000;
|
|
||||||
const MAX_YAML_COLLECTION_ITEMS: usize = 4_096;
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
|
|
||||||
pub async fn export_operation(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
operation_id: &crank_core::OperationId,
|
|
||||||
query: ExportQuery,
|
|
||||||
) -> Result<String, ApiError> {
|
|
||||||
let version = match query.version {
|
|
||||||
Some(version) => version,
|
|
||||||
None => {
|
|
||||||
self.get_operation(workspace_id, operation_id)
|
|
||||||
.await?
|
|
||||||
.current_draft_version
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let record = self
|
|
||||||
.get_operation_version(workspace_id, operation_id, version)
|
|
||||||
.await?;
|
|
||||||
let aggregate = self.get_operation(workspace_id, operation_id).await?;
|
|
||||||
let exportable = record.status == OperationStatus::Published
|
|
||||||
|| (aggregate.status != OperationStatus::Archived
|
|
||||||
&& record.status == OperationStatus::Draft
|
|
||||||
&& aggregate.current_draft_version == version);
|
|
||||||
if !exportable {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"only the active current Draft or a Published Version can be exported",
|
|
||||||
serde_json::json!({
|
|
||||||
"error_code": "operation_invalid_transition",
|
|
||||||
"version": version
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let operation = PortableOperation::from_registry(&record.snapshot);
|
|
||||||
reject_credential_material(&operation)?;
|
|
||||||
let document = YamlOperationDocument {
|
|
||||||
format_version: "2".to_owned(),
|
|
||||||
kind: "operation".to_owned(),
|
|
||||||
operation,
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_yaml::to_string(&document)
|
|
||||||
.map_err(|_| ApiError::internal("portable yaml serialization failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
|
|
||||||
pub async fn import_operation(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
query: ImportQuery,
|
|
||||||
yaml_document: &str,
|
|
||||||
if_match: Option<&str>,
|
|
||||||
) -> Result<ImportResponse, ApiError> {
|
|
||||||
reject_yaml_anchors_and_aliases(yaml_document)?;
|
|
||||||
let parsed = parse_bounded_yaml(yaml_document)?;
|
|
||||||
let format_version = parsed
|
|
||||||
.as_mapping()
|
|
||||||
.and_then(|mapping| mapping.get(YamlValue::String("format_version".to_owned())))
|
|
||||||
.and_then(YamlValue::as_str)
|
|
||||||
.ok_or_else(yaml_invalid)?;
|
|
||||||
|
|
||||||
let (operation, warnings) = match format_version {
|
|
||||||
"2" => {
|
|
||||||
let document: YamlOperationDocument =
|
|
||||||
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
|
|
||||||
if document.kind != "operation" || document.format_version != "2" {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
(document.operation, Vec::new())
|
|
||||||
}
|
|
||||||
"1" => {
|
|
||||||
let document: LegacyYamlOperationDocument =
|
|
||||||
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
|
|
||||||
if document.kind != "operation" || document.format_version != "1" {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
let warnings = import_guidance_warnings(&document.operation);
|
|
||||||
(
|
|
||||||
PortableOperation::from_registry(&document.operation),
|
|
||||||
warnings,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => return Err(yaml_unsupported()),
|
|
||||||
};
|
|
||||||
reject_credential_material(&operation)?;
|
|
||||||
let payload = operation.to_payload();
|
|
||||||
|
|
||||||
match query.mode {
|
|
||||||
ImportMode::Create => {
|
|
||||||
let created = self.create_operation(workspace_id, payload).await?;
|
|
||||||
Ok(ImportResponse {
|
|
||||||
operation_id: created.operation_id,
|
|
||||||
workspace_id: created.workspace_id,
|
|
||||||
version: created.version,
|
|
||||||
import_mode: ImportMode::Create,
|
|
||||||
warnings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ImportMode::Upsert => {
|
|
||||||
if let Some(existing) = self
|
|
||||||
.find_operation_by_name(workspace_id, &operation.name)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
if existing.status == OperationStatus::Archived {
|
|
||||||
return Err(crank_registry::RegistryError::OperationArchived {
|
|
||||||
operation_id: existing.id.as_str().to_owned(),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let current = self
|
|
||||||
.get_operation_version(
|
|
||||||
workspace_id,
|
|
||||||
&existing.id,
|
|
||||||
existing.current_draft_version,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if operation.matches_registry(¤t.snapshot) {
|
|
||||||
let expected_state = OperationStateExpectation {
|
|
||||||
current_draft_version: existing.current_draft_version,
|
|
||||||
status: existing.status,
|
|
||||||
latest_published_version: existing.latest_published_version,
|
|
||||||
};
|
|
||||||
self.registry
|
|
||||||
.verify_operation_state(workspace_id, &existing.id, &expected_state)
|
|
||||||
.await?;
|
|
||||||
return Ok(ImportResponse {
|
|
||||||
operation_id: existing.id.as_str().to_owned(),
|
|
||||||
workspace_id: workspace_id.as_str().to_owned(),
|
|
||||||
version: existing.current_draft_version,
|
|
||||||
import_mode: ImportMode::Upsert,
|
|
||||||
warnings,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let current_detail = self.get_operation(workspace_id, &existing.id).await?;
|
|
||||||
let expected_state = OperationStateExpectation {
|
|
||||||
current_draft_version: current_detail.current_draft_version,
|
|
||||||
status: current_detail.status,
|
|
||||||
latest_published_version: current_detail.latest_published_version,
|
|
||||||
};
|
|
||||||
let expected_etag = Self::operation_state_etag(¤t_detail);
|
|
||||||
let provided_etag = if_match.ok_or_else(|| {
|
|
||||||
ApiError::precondition_required_with_context(
|
|
||||||
"If-Match is required when YAML upsert changes an existing Operation",
|
|
||||||
serde_json::json!({
|
|
||||||
"error_code": "operation_precondition_required",
|
|
||||||
"current_version": existing.current_draft_version,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if provided_etag != expected_etag {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"Operation state changed; reload before retrying YAML upsert",
|
|
||||||
serde_json::json!({
|
|
||||||
"error_code": "operation_stale_version",
|
|
||||||
"current_version": existing.current_draft_version,
|
|
||||||
"recovery": "reload"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let created = self
|
|
||||||
.create_version(
|
|
||||||
workspace_id,
|
|
||||||
&existing.id,
|
|
||||||
NewVersionPayload {
|
|
||||||
operation: payload,
|
|
||||||
change_note: Some("yaml upsert".to_owned()),
|
|
||||||
},
|
|
||||||
&expected_state,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let response = ImportResponse {
|
|
||||||
operation_id: created.operation_id,
|
|
||||||
workspace_id: created.workspace_id,
|
|
||||||
version: created.version,
|
|
||||||
import_mode: ImportMode::Upsert,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
info!(
|
|
||||||
name: "admin.operation.imported",
|
|
||||||
operation_id = %response.operation_id,
|
|
||||||
version = response.version,
|
|
||||||
"operation imported by upsert"
|
|
||||||
);
|
|
||||||
Ok(response)
|
|
||||||
} else {
|
|
||||||
let created = self.create_operation(workspace_id, payload).await?;
|
|
||||||
Ok(ImportResponse {
|
|
||||||
operation_id: created.operation_id,
|
|
||||||
workspace_id: created.workspace_id,
|
|
||||||
version: created.version,
|
|
||||||
import_mode: ImportMode::Upsert,
|
|
||||||
warnings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PortableOperation {
|
|
||||||
fn from_registry(operation: &crank_registry::RegistryOperation) -> Self {
|
|
||||||
Self {
|
|
||||||
name: operation.name.clone(),
|
|
||||||
display_name: operation.display_name.clone(),
|
|
||||||
category: operation.category.clone(),
|
|
||||||
protocol: operation.protocol,
|
|
||||||
security_level: operation.security_level,
|
|
||||||
target: operation.target.clone(),
|
|
||||||
input_schema: operation.input_schema.clone(),
|
|
||||||
output_schema: operation.output_schema.clone(),
|
|
||||||
input_mapping: operation.input_mapping.clone(),
|
|
||||||
output_mapping: operation.output_mapping.clone(),
|
|
||||||
execution_config: operation.execution_config.clone(),
|
|
||||||
tool_description: operation.tool_description.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_payload(&self) -> OperationPayload {
|
|
||||||
OperationPayload {
|
|
||||||
name: self.name.clone(),
|
|
||||||
display_name: self.display_name.clone(),
|
|
||||||
category: self.category.clone(),
|
|
||||||
protocol: self.protocol,
|
|
||||||
security_level: self.security_level,
|
|
||||||
target: self.target.clone(),
|
|
||||||
input_schema: self.input_schema.clone(),
|
|
||||||
output_schema: self.output_schema.clone(),
|
|
||||||
input_mapping: self.input_mapping.clone(),
|
|
||||||
output_mapping: self.output_mapping.clone(),
|
|
||||||
execution_config: self.execution_config.clone(),
|
|
||||||
tool_description: self.tool_description.clone(),
|
|
||||||
wizard_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_registry(&self, operation: &crank_registry::RegistryOperation) -> bool {
|
|
||||||
self.name == operation.name
|
|
||||||
&& self.display_name == operation.display_name
|
|
||||||
&& self.category == operation.category
|
|
||||||
&& self.protocol == operation.protocol
|
|
||||||
&& self.security_level == operation.security_level
|
|
||||||
&& self.target == operation.target
|
|
||||||
&& self.input_schema == operation.input_schema
|
|
||||||
&& self.output_schema == operation.output_schema
|
|
||||||
&& self.input_mapping == operation.input_mapping
|
|
||||||
&& self.output_mapping == operation.output_mapping
|
|
||||||
&& self.execution_config == operation.execution_config
|
|
||||||
&& self.tool_description == operation.tool_description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bounded_yaml(document: &str) -> Result<YamlValue, ApiError> {
|
|
||||||
if document.is_empty() {
|
|
||||||
return Err(yaml_invalid());
|
|
||||||
}
|
|
||||||
if document.len() > MAX_YAML_BYTES {
|
|
||||||
return Err(ApiError::payload_too_large_with_context(
|
|
||||||
"operation yaml exceeds the 256 KiB limit",
|
|
||||||
serde_json::json!({ "error_code": "operation_yaml_too_large" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if document
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.len() > MAX_YAML_LINE_OR_SCALAR_BYTES)
|
|
||||||
{
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
let separators = document.lines().filter(|line| line.trim() == "---").count();
|
|
||||||
if separators > 1 || document.lines().any(|line| line.trim() == "...") {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
DuplicateChecked::deserialize(serde_yaml::Deserializer::from_str(document))
|
|
||||||
.map_err(|_| yaml_invalid())?;
|
|
||||||
let parsed: YamlValue = serde_yaml::from_str(document).map_err(|_| yaml_invalid())?;
|
|
||||||
let mut nodes = 0;
|
|
||||||
validate_yaml_value(&parsed, 0, &mut nodes)?;
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A bounded preflight pass which rejects duplicate mapping keys before
|
|
||||||
/// `serde_yaml::Value` can collapse them. Values are deliberately discarded:
|
|
||||||
/// the authoritative typed parse follows only after this structural check.
|
|
||||||
struct DuplicateChecked;
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for DuplicateChecked {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
deserializer.deserialize_any(DuplicateCheckedVisitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DuplicateCheckedVisitor;
|
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for DuplicateCheckedVisitor {
|
|
||||||
type Value = DuplicateChecked;
|
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str("a YAML value without duplicate mapping keys")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
|
||||||
where
|
|
||||||
A: MapAccess<'de>,
|
|
||||||
{
|
|
||||||
let mut keys = Vec::<YamlValue>::new();
|
|
||||||
while let Some(key) = map.next_key::<YamlValue>()? {
|
|
||||||
if keys.iter().any(|existing| existing == &key) {
|
|
||||||
return Err(de::Error::custom("duplicate mapping key"));
|
|
||||||
}
|
|
||||||
keys.push(key);
|
|
||||||
map.next_value::<DuplicateChecked>()?;
|
|
||||||
}
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
|
|
||||||
where
|
|
||||||
A: SeqAccess<'de>,
|
|
||||||
{
|
|
||||||
while sequence.next_element::<DuplicateChecked>()?.is_some() {}
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_string<E>(self, _: String) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_none<E>(self) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_unit<E>(self) -> Result<Self::Value, E> {
|
|
||||||
Ok(DuplicateChecked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
DuplicateChecked::deserialize(deserializer)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
DuplicateChecked::deserialize(deserializer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_yaml_value(value: &YamlValue, depth: usize, nodes: &mut usize) -> Result<(), ApiError> {
|
|
||||||
if depth > MAX_YAML_DEPTH {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
*nodes = nodes.saturating_add(1);
|
|
||||||
if *nodes > MAX_YAML_NODES {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
match value {
|
|
||||||
YamlValue::Sequence(values) => {
|
|
||||||
if values.len() > MAX_YAML_COLLECTION_ITEMS {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
for value in values {
|
|
||||||
validate_yaml_value(value, depth + 1, nodes)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
YamlValue::Mapping(values) => {
|
|
||||||
if values.len() > MAX_YAML_COLLECTION_ITEMS {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
for (key, value) in values {
|
|
||||||
validate_yaml_value(key, depth + 1, nodes)?;
|
|
||||||
validate_yaml_value(value, depth + 1, nodes)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
YamlValue::String(value) if value.len() > MAX_YAML_LINE_OR_SCALAR_BYTES => {
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
YamlValue::Tagged(_) => return Err(yaml_unsupported()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reject_credential_material(operation: &PortableOperation) -> Result<(), ApiError> {
|
|
||||||
let sensitive_name = |name: &str| {
|
|
||||||
let normalized = name.to_ascii_lowercase();
|
|
||||||
let segments = normalized.split(['-', '_', '.']).collect::<Vec<_>>();
|
|
||||||
matches!(
|
|
||||||
normalized.as_str(),
|
|
||||||
"authorization" | "proxy-authorization" | "cookie" | "set-cookie"
|
|
||||||
) || segments
|
|
||||||
.iter()
|
|
||||||
.any(|segment| matches!(*segment, "auth" | "token" | "secret" | "cookie"))
|
|
||||||
|| segments.windows(2).any(|parts| parts == ["api", "key"])
|
|
||||||
};
|
|
||||||
let sensitive_value = |value: &str| {
|
|
||||||
let normalized = value.trim().to_ascii_lowercase();
|
|
||||||
normalized.starts_with("bearer ")
|
|
||||||
|| normalized.starts_with("basic ")
|
|
||||||
|| normalized.starts_with("digest ")
|
|
||||||
|| normalized.contains("api_key=")
|
|
||||||
|| normalized.contains("access_token=")
|
|
||||||
};
|
|
||||||
let target_headers = match &operation.target {
|
|
||||||
Target::Rest(target) => &target.static_headers,
|
|
||||||
};
|
|
||||||
if target_headers
|
|
||||||
.iter()
|
|
||||||
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
|
|
||||||
|| operation
|
|
||||||
.execution_config
|
|
||||||
.headers
|
|
||||||
.iter()
|
|
||||||
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
|
|
||||||
{
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"portable yaml contains credential-bearing headers",
|
|
||||||
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reject_yaml_anchors_and_aliases(document: &str) -> Result<(), ApiError> {
|
|
||||||
let mut single_quote = false;
|
|
||||||
let mut double_quote = false;
|
|
||||||
let mut escaped = false;
|
|
||||||
let mut comment = false;
|
|
||||||
let mut previous = '\n';
|
|
||||||
let chars = document.chars().collect::<Vec<_>>();
|
|
||||||
for (index, character) in chars.iter().copied().enumerate() {
|
|
||||||
if character == '\n' {
|
|
||||||
comment = false;
|
|
||||||
previous = character;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if comment {
|
|
||||||
previous = character;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if double_quote {
|
|
||||||
if escaped {
|
|
||||||
escaped = false;
|
|
||||||
} else if character == '\\' {
|
|
||||||
escaped = true;
|
|
||||||
} else if character == '"' {
|
|
||||||
double_quote = false;
|
|
||||||
}
|
|
||||||
previous = character;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if single_quote {
|
|
||||||
if character == '\'' {
|
|
||||||
single_quote = false;
|
|
||||||
}
|
|
||||||
previous = character;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match character {
|
|
||||||
'#' => comment = true,
|
|
||||||
'"' => double_quote = true,
|
|
||||||
'\'' => single_quote = true,
|
|
||||||
'&' | '*'
|
|
||||||
if (previous.is_whitespace() || matches!(previous, ':' | '[' | '{' | ','))
|
|
||||||
&& chars.get(index + 1).is_some_and(|next| {
|
|
||||||
next.is_ascii_alphanumeric() || matches!(next, '_' | '-')
|
|
||||||
}) =>
|
|
||||||
{
|
|
||||||
return Err(yaml_unsupported());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
previous = character;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yaml_invalid() -> ApiError {
|
|
||||||
ApiError::unprocessable_with_context(
|
|
||||||
"operation yaml is invalid",
|
|
||||||
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yaml_unsupported() -> ApiError {
|
|
||||||
ApiError::unprocessable_with_context(
|
|
||||||
"operation yaml uses an unsupported construct",
|
|
||||||
serde_json::json!({ "error_code": "operation_yaml_unsupported" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::parse_bounded_yaml;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn portable_v2_schema_freezes_the_closed_top_level_contract() {
|
|
||||||
let schema: Value = serde_json::from_str(include_str!(
|
|
||||||
"../../../../docs/schemas/operation-export-v2.schema.json"
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(schema["properties"]["format_version"]["const"], "2");
|
|
||||||
assert_eq!(schema["properties"]["kind"]["const"], "operation");
|
|
||||||
assert_eq!(schema["additionalProperties"], false);
|
|
||||||
assert_eq!(
|
|
||||||
schema["$defs"]["portable_operation"]["additionalProperties"],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
schema["$defs"]["portable_operation"]["properties"]["security_level"]["enum"],
|
|
||||||
serde_json::json!(["standard"])
|
|
||||||
);
|
|
||||||
for name in [
|
|
||||||
"rest_target",
|
|
||||||
"schema",
|
|
||||||
"mapping_set",
|
|
||||||
"mapping_rule",
|
|
||||||
"execution_config",
|
|
||||||
"tool_description",
|
|
||||||
] {
|
|
||||||
assert_eq!(schema["$defs"][name]["additionalProperties"], false);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
schema["$defs"]["portable_operation"]["properties"]["target"]["$ref"],
|
|
||||||
"#/$defs/rest_target"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bounded_yaml_rejects_duplicate_keys_before_typed_parsing() {
|
|
||||||
let document =
|
|
||||||
"format_version: '2'\nkind: operation\nkind: attacker_override\noperation: {}\n";
|
|
||||||
let error = parse_bounded_yaml(document).expect_err("duplicate key must fail closed");
|
|
||||||
match error {
|
|
||||||
crate::error::ApiError::Unprocessable { context, .. } => assert_eq!(
|
|
||||||
context.and_then(|value| value["error_code"].as_str().map(str::to_owned)),
|
|
||||||
Some("operation_yaml_invalid".to_owned())
|
|
||||||
),
|
|
||||||
other => panic!("unexpected error: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,866 +0,0 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
|
|
||||||
use crank_artifacts::{ArtifactError, MAX_ARTIFACT_BYTES};
|
|
||||||
use crank_core::{
|
|
||||||
ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity,
|
|
||||||
WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_import::rest::{
|
|
||||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, NORMALIZER_VERSION,
|
|
||||||
PROJECTION_VERSION, operation_draft_from_candidate,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
|
|
||||||
CreateArtifactSourceRequest, CreateImportJobRequest, FinishImportJobRequest,
|
|
||||||
ImportConflictMode, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope,
|
|
||||||
ImportJobStatus, ImportOperationDraft, RegistryError,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
|
||||||
use tracing::{info, instrument};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
|
|
||||||
OpenApiImportCreatedOperation, OpenApiImportPreviewResponse, OpenApiImportSkippedOperation,
|
|
||||||
OpenApiUpload, OpenApiUploadLocale, OperationPayload, new_prefixed_id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mod external_references;
|
|
||||||
mod job_contract;
|
|
||||||
use external_references::ImportReplayContext;
|
|
||||||
use job_contract::{
|
|
||||||
SourceDetachGuard, import_job_dependencies, import_job_normalization_config, import_job_source,
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMPORT_JOB_TTL_HOURS: i64 = 24;
|
|
||||||
const OPENAPI_PARSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
|
||||||
const OPENAPI_PARSE_CONCURRENCY: usize = 4;
|
|
||||||
static OPENAPI_PARSE_SLOTS: tokio::sync::Semaphore =
|
|
||||||
tokio::sync::Semaphore::const_new(OPENAPI_PARSE_CONCURRENCY);
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
#[instrument(skip(self, upload), fields(workspace_id = %workspace_id.as_str()))]
|
|
||||||
pub async fn preview_openapi_import(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
upload: OpenApiUpload,
|
|
||||||
) -> Result<OpenApiImportPreviewResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
|
|
||||||
validate_openapi_upload(&upload)?;
|
|
||||||
let OpenApiUpload {
|
|
||||||
bytes,
|
|
||||||
mime_type,
|
|
||||||
locale,
|
|
||||||
} = upload;
|
|
||||||
let store = self.artifact_store.clone();
|
|
||||||
let artifact = tokio::task::spawn_blocking(move || store.put_registered(&bytes))
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "storage_unavailable"))?
|
|
||||||
.map_err(|error| artifact_error(locale, error))?;
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS);
|
|
||||||
let job_id = ImportJobId::new(new_prefixed_id("imp"));
|
|
||||||
let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi"));
|
|
||||||
let source = self
|
|
||||||
.registry
|
|
||||||
.create_artifact_source(CreateArtifactSourceRequest {
|
|
||||||
workspace_id,
|
|
||||||
source_id: &source_id,
|
|
||||||
artifact: &artifact,
|
|
||||||
mime_type: &mime_type,
|
|
||||||
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
||||||
created_at: now,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
let mut detach_guard = SourceDetachGuard::new(
|
|
||||||
self.registry.clone(),
|
|
||||||
workspace_id.clone(),
|
|
||||||
source_id.clone(),
|
|
||||||
source.updated_at,
|
|
||||||
);
|
|
||||||
let verified = match self
|
|
||||||
.registry
|
|
||||||
.read_artifact_source(
|
|
||||||
std::sync::Arc::clone(&self.artifact_store),
|
|
||||||
workspace_id,
|
|
||||||
&source_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(verified) => verified,
|
|
||||||
Err(error) => {
|
|
||||||
detach_guard.detach_now().await;
|
|
||||||
return Err(ApiError::from(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if verified.source.blob.artifact_ref != *artifact.artifact_ref() {
|
|
||||||
detach_guard.detach_now().await;
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let mut dependencies = self
|
|
||||||
.materialize_external_reference_snapshots(workspace_id, &verified.bytes, now)
|
|
||||||
.await;
|
|
||||||
let parsed = match parse_verified_preview(
|
|
||||||
verified.bytes,
|
|
||||||
artifact.artifact_ref().digest_hex().to_owned(),
|
|
||||||
dependencies.snapshots.clone(),
|
|
||||||
self.external_reference_normalization.clone(),
|
|
||||||
locale,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(preview) => preview,
|
|
||||||
Err(error) => {
|
|
||||||
dependencies.detach_all().await;
|
|
||||||
detach_guard.detach_now().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if parsed
|
|
||||||
.preview
|
|
||||||
.groups
|
|
||||||
.iter()
|
|
||||||
.all(|group| group.operations.is_empty())
|
|
||||||
{
|
|
||||||
dependencies.detach_all().await;
|
|
||||||
detach_guard.detach_now().await;
|
|
||||||
return Err(ApiError::openapi_upload(locale, "no_methods"));
|
|
||||||
}
|
|
||||||
let source_envelope = ImportJobSourceEnvelope {
|
|
||||||
source_id,
|
|
||||||
digest: artifact.artifact_ref().clone(),
|
|
||||||
};
|
|
||||||
let preview_value = serde_json::to_value(&parsed.preview)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
|
||||||
let preview_digest = preview_digest(&preview_value)?;
|
|
||||||
let preview_payload = json!({
|
|
||||||
"source": {
|
|
||||||
"source_id": source_envelope.source_id.as_str(),
|
|
||||||
"digest": source_envelope.digest.as_str(),
|
|
||||||
},
|
|
||||||
"dependencies": dependencies.payload(),
|
|
||||||
"dependency_snapshots": dependencies.snapshot_payload(),
|
|
||||||
"preview": preview_value,
|
|
||||||
"preview_digest": preview_digest,
|
|
||||||
"normalization": {
|
|
||||||
"normalizer_version": NORMALIZER_VERSION,
|
|
||||||
"projection_version": PROJECTION_VERSION,
|
|
||||||
"ir_fingerprint": parsed.ir_fingerprint,
|
|
||||||
"config": self.external_reference_normalization,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(error) = self
|
|
||||||
.registry
|
|
||||||
.create_import_job(CreateImportJobRequest {
|
|
||||||
id: &job_id,
|
|
||||||
workspace_id,
|
|
||||||
kind: ImportJobKind::OpenApi,
|
|
||||||
source_format: &parsed.preview.source.format,
|
|
||||||
source_version: parsed.preview.source.version.as_deref(),
|
|
||||||
status: ImportJobStatus::Pending,
|
|
||||||
source: &source_envelope,
|
|
||||||
preview_payload: &preview_payload,
|
|
||||||
created_at: &now,
|
|
||||||
expires_at: &expires_at,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
dependencies.detach_all().await;
|
|
||||||
detach_guard.detach_now().await;
|
|
||||||
return Err(ApiError::from(error));
|
|
||||||
}
|
|
||||||
dependencies.disarm();
|
|
||||||
detach_guard.disarm();
|
|
||||||
|
|
||||||
Ok(OpenApiImportPreviewResponse {
|
|
||||||
job_id: job_id.as_str().to_owned(),
|
|
||||||
expires_at: expires_at
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
preview: parsed.preview,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), job_id = %job_id.as_str()))]
|
|
||||||
pub async fn create_openapi_import(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
job_id: &ImportJobId,
|
|
||||||
payload: OpenApiImportCreatePayload,
|
|
||||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
|
||||||
self.create_openapi_import_with_locale(
|
|
||||||
workspace_id,
|
|
||||||
job_id,
|
|
||||||
payload,
|
|
||||||
OpenApiUploadLocale::En,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_openapi_import_with_locale(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
job_id: &ImportJobId,
|
|
||||||
payload: OpenApiImportCreatePayload,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
|
|
||||||
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"unsupported conflict_mode; supported values are skip and rename",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let job = self
|
|
||||||
.registry
|
|
||||||
.get_import_job(workspace_id, job_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
"import job was not found",
|
|
||||||
json!({ "job_id": job_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if job.expires_at <= OffsetDateTime::now_utc() {
|
|
||||||
return Err(ApiError::validation("import preview has expired"));
|
|
||||||
}
|
|
||||||
if job.kind != ImportJobKind::OpenApi {
|
|
||||||
return Err(ApiError::validation("import job kind is not openapi"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = payload
|
|
||||||
.selected_operation_keys
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
if selected.is_empty() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"selected_operation_keys must contain at least one operation",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let finished_at = OffsetDateTime::now_utc();
|
|
||||||
let application_key = openapi_application_key(&payload)?;
|
|
||||||
let conflict_mode = if payload.conflict_mode == "skip" {
|
|
||||||
ImportConflictMode::Skip
|
|
||||||
} else {
|
|
||||||
ImportConflictMode::Rename
|
|
||||||
};
|
|
||||||
let replay_context = ImportReplayContext {
|
|
||||||
workspace_id,
|
|
||||||
job_id,
|
|
||||||
application_key: &application_key,
|
|
||||||
conflict_mode,
|
|
||||||
finished_at: &finished_at,
|
|
||||||
};
|
|
||||||
if job.status == ImportJobStatus::Completed {
|
|
||||||
let applied = self
|
|
||||||
.registry
|
|
||||||
.apply_import_job(ApplyImportJobRequest {
|
|
||||||
id: job_id,
|
|
||||||
workspace_id,
|
|
||||||
application_key: &application_key,
|
|
||||||
conflict_mode,
|
|
||||||
operations: &[],
|
|
||||||
pre_skipped: &[],
|
|
||||||
finished_at: &finished_at,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
return Ok(openapi_import_response(applied));
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = match import_job_source(&job.preview_payload, locale) {
|
|
||||||
Ok(source) => source,
|
|
||||||
Err(error) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_source_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let verified = match self
|
|
||||||
.registry
|
|
||||||
.read_artifact_source(
|
|
||||||
std::sync::Arc::clone(&self.artifact_store),
|
|
||||||
workspace_id,
|
|
||||||
&source.source_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(verified) => verified,
|
|
||||||
Err(
|
|
||||||
error @ (RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable),
|
|
||||||
) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_source_verification_failed",
|
|
||||||
openapi_source_error(locale, error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(error @ RegistryError::SourceIntegrity) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_source_verification_failed",
|
|
||||||
openapi_source_error(locale, error),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(openapi_source_error(locale, error)),
|
|
||||||
};
|
|
||||||
if verified.source.blob.artifact_ref != source.digest {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_source_verification_failed",
|
|
||||||
ApiError::openapi_upload(locale, "source_integrity"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let replay_contract = match import_replay_contract(&job.preview_payload, locale) {
|
|
||||||
Ok(contract) => contract,
|
|
||||||
Err(error) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_replay_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let parsed = match replay_contract {
|
|
||||||
ImportReplayContract::PersistedV2(parsed) => parsed,
|
|
||||||
ImportReplayContract::LegacyV1 | ImportReplayContract::Current => {
|
|
||||||
let legacy_v1 = matches!(replay_contract, ImportReplayContract::LegacyV1);
|
|
||||||
let dependency_snapshots = match self
|
|
||||||
.read_import_job_dependencies(workspace_id, &job.preview_payload, locale)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(snapshots) => snapshots,
|
|
||||||
Err(error) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_dependency_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let normalization_config = match import_job_normalization_config(
|
|
||||||
&job.preview_payload,
|
|
||||||
&self.external_reference_normalization,
|
|
||||||
legacy_v1,
|
|
||||||
locale,
|
|
||||||
) {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(error) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_replay_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let parsed = match parse_verified_preview(
|
|
||||||
verified.bytes,
|
|
||||||
source.digest.digest_hex().to_owned(),
|
|
||||||
dependency_snapshots,
|
|
||||||
normalization_config,
|
|
||||||
locale,
|
|
||||||
legacy_v1,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(error) => {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_replay_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(error) = verify_preview_contract(&job.preview_payload, &parsed, locale) {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"import_replay_verification_failed",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
parsed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if preview_has_blocker(&parsed.preview, &selected) {
|
|
||||||
return self
|
|
||||||
.fail_openapi_import_or_replay(
|
|
||||||
&replay_context,
|
|
||||||
"reference_resolution_blocked",
|
|
||||||
ApiError::openapi_upload(locale, "invalid_document"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let mut candidates = BTreeMap::new();
|
|
||||||
for group in &parsed.preview.groups {
|
|
||||||
for operation in &group.operations {
|
|
||||||
candidates.insert(operation.key.clone(), operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut skipped = Vec::new();
|
|
||||||
let mut operations = Vec::new();
|
|
||||||
|
|
||||||
for operation_key in selected {
|
|
||||||
let Some(candidate) = candidates.get(&operation_key) else {
|
|
||||||
skipped.push(crank_registry::SkippedImportOperation {
|
|
||||||
operation_key,
|
|
||||||
name: String::new(),
|
|
||||||
reason: "operation was not found in import preview".to_owned(),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut draft =
|
|
||||||
operation_draft_from_candidate(candidate, payload.server_url.as_deref());
|
|
||||||
attach_import_findings(&mut draft, candidate);
|
|
||||||
let operation = self.new_operation_snapshot(OperationPayload {
|
|
||||||
name: draft.name.clone(),
|
|
||||||
display_name: draft.display_name.clone(),
|
|
||||||
category: draft.category,
|
|
||||||
protocol: Protocol::Rest,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
target: crank_core::Target::Rest(draft.target),
|
|
||||||
input_schema: draft.input_schema,
|
|
||||||
output_schema: draft.output_schema,
|
|
||||||
input_mapping: draft.input_mapping,
|
|
||||||
output_mapping: draft.output_mapping,
|
|
||||||
execution_config: ExecutionConfig {
|
|
||||||
timeout_ms: 10_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
approval_policy: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
tool_description: draft.tool_description,
|
|
||||||
wizard_state: draft.wizard_state,
|
|
||||||
})?;
|
|
||||||
operations.push(ImportOperationDraft {
|
|
||||||
operation_key: candidate.key.clone(),
|
|
||||||
operation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let applied = self
|
|
||||||
.registry
|
|
||||||
.apply_import_job(ApplyImportJobRequest {
|
|
||||||
id: job_id,
|
|
||||||
workspace_id,
|
|
||||||
application_key: &application_key,
|
|
||||||
conflict_mode,
|
|
||||||
operations: &operations,
|
|
||||||
pre_skipped: &skipped,
|
|
||||||
finished_at: &finished_at,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(openapi_import_response(applied))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonical_external_document_uri(base: Option<&str>, reference: &str) -> Option<String> {
|
|
||||||
if reference.starts_with('#') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut url = match base {
|
|
||||||
Some(base) => url::Url::parse(base).ok()?.join(reference).ok()?,
|
|
||||||
None => url::Url::parse(reference).ok()?,
|
|
||||||
};
|
|
||||||
if !matches!(url.scheme(), "http" | "https")
|
|
||||||
|| !url.username().is_empty()
|
|
||||||
|| url.password().is_some()
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
url.set_fragment(None);
|
|
||||||
Some(url.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn openapi_import_response(applied: ImportJobApplyResult) -> OpenApiImportCreateResponse {
|
|
||||||
let created = applied
|
|
||||||
.created
|
|
||||||
.iter()
|
|
||||||
.map(|operation| OpenApiImportCreatedOperation {
|
|
||||||
operation_key: operation.operation_key.clone(),
|
|
||||||
operation_id: operation.operation_id.as_str().to_owned(),
|
|
||||||
name: operation.name.clone(),
|
|
||||||
version: operation.version,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut findings = applied
|
|
||||||
.created
|
|
||||||
.iter()
|
|
||||||
.filter_map(|operation| {
|
|
||||||
operation
|
|
||||||
.renamed_from
|
|
||||||
.as_ref()
|
|
||||||
.map(|previous_name| ImportFinding {
|
|
||||||
code: "operation_name_renamed".to_owned(),
|
|
||||||
severity: ImportFindingSeverity::Info,
|
|
||||||
message: format!(
|
|
||||||
"Операция {previous_name} уже существует, новый черновик создан как {}.",
|
|
||||||
operation.name
|
|
||||||
),
|
|
||||||
operation_key: Some(operation.operation_key.clone()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let skipped = applied
|
|
||||||
.skipped
|
|
||||||
.into_iter()
|
|
||||||
.map(|operation| {
|
|
||||||
let reason = operation.reason.clone();
|
|
||||||
let name = operation.name.clone();
|
|
||||||
findings.push(ImportFinding {
|
|
||||||
code: reason.clone(),
|
|
||||||
severity: ImportFindingSeverity::Warning,
|
|
||||||
message: if name.is_empty() {
|
|
||||||
"Выбранная операция отсутствует в исходном preview и была пропущена.".to_owned()
|
|
||||||
} else {
|
|
||||||
format!("Операция {name} уже существует и была пропущена.")
|
|
||||||
},
|
|
||||||
operation_key: Some(operation.operation_key.clone()),
|
|
||||||
});
|
|
||||||
OpenApiImportSkippedOperation {
|
|
||||||
operation_key: operation.operation_key.clone(),
|
|
||||||
name: operation.name,
|
|
||||||
reason,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
info!(
|
|
||||||
name: "admin.openapi_import.completed",
|
|
||||||
created = created.len(),
|
|
||||||
skipped = skipped.len(),
|
|
||||||
"openapi import created drafts"
|
|
||||||
);
|
|
||||||
OpenApiImportCreateResponse {
|
|
||||||
created,
|
|
||||||
skipped,
|
|
||||||
findings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> {
|
|
||||||
if upload.bytes.is_empty() {
|
|
||||||
return Err(ApiError::openapi_upload(upload.locale, "empty_file"));
|
|
||||||
}
|
|
||||||
if upload.bytes.len() > MAX_ARTIFACT_BYTES {
|
|
||||||
return Err(ApiError::openapi_upload(upload.locale, "file_too_large"));
|
|
||||||
}
|
|
||||||
if !matches!(
|
|
||||||
upload.mime_type.as_str(),
|
|
||||||
"application/yaml"
|
|
||||||
| "application/x-yaml"
|
|
||||||
| "text/yaml"
|
|
||||||
| "text/x-yaml"
|
|
||||||
| "application/json"
|
|
||||||
| "application/openapi+json"
|
|
||||||
| "application/octet-stream"
|
|
||||||
) {
|
|
||||||
return Err(ApiError::openapi_upload(
|
|
||||||
upload.locale,
|
|
||||||
"invalid_media_type",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if std::str::from_utf8(&upload.bytes).is_err() {
|
|
||||||
return Err(ApiError::openapi_upload(upload.locale, "invalid_utf8"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ParsedOpenApiPreview {
|
|
||||||
preview: crank_import::rest::ImportPreview,
|
|
||||||
ir_fingerprint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ImportReplayContract {
|
|
||||||
LegacyV1,
|
|
||||||
PersistedV2(ParsedOpenApiPreview),
|
|
||||||
Current,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_replay_contract(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<ImportReplayContract, ApiError> {
|
|
||||||
let Some(normalization) = payload.get("normalization") else {
|
|
||||||
return Ok(ImportReplayContract::LegacyV1);
|
|
||||||
};
|
|
||||||
let normalizer = normalization
|
|
||||||
.get("normalizer_version")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let projection = normalization
|
|
||||||
.get("projection_version")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let ir_fingerprint = normalization
|
|
||||||
.get("ir_fingerprint")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| is_lower_sha256(value))
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let expected_preview_digest = payload
|
|
||||||
.get("preview_digest")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| is_lower_sha256(value))
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
|
|
||||||
if normalizer == NORMALIZER_VERSION && projection == PROJECTION_VERSION {
|
|
||||||
return Ok(ImportReplayContract::Current);
|
|
||||||
}
|
|
||||||
if normalizer != "normalized-ir-v2" || projection != "preview-v2" {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let preview_value = payload
|
|
||||||
.get("preview")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
if preview_digest(&preview_value)? != expected_preview_digest {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let preview = serde_json::from_value(preview_value)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
Ok(ImportReplayContract::PersistedV2(ParsedOpenApiPreview {
|
|
||||||
preview,
|
|
||||||
ir_fingerprint: ir_fingerprint.to_owned(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_lower_sha256(value: &str) -> bool {
|
|
||||||
value.len() == 64
|
|
||||||
&& value
|
|
||||||
.bytes()
|
|
||||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse_verified_preview(
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
digest: String,
|
|
||||||
snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
|
|
||||||
normalization_config: crank_import::rest::NormalizationConfig,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
legacy_v1: bool,
|
|
||||||
) -> Result<ParsedOpenApiPreview, ApiError> {
|
|
||||||
let started = tokio::time::Instant::now();
|
|
||||||
let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire())
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?;
|
|
||||||
let parsing = tokio::task::spawn_blocking(move || {
|
|
||||||
// Keep the permit inside the blocking task. Timing out the caller
|
|
||||||
// cannot cancel CPU work already running, but abandoned parsers remain
|
|
||||||
// globally bounded and release capacity when they actually finish.
|
|
||||||
let _permit = permit;
|
|
||||||
let document = std::str::from_utf8(&bytes)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?;
|
|
||||||
if legacy_v1 {
|
|
||||||
return crank_import::rest::preview_document_legacy_v1(document)
|
|
||||||
.map(|preview| ParsedOpenApiPreview {
|
|
||||||
preview,
|
|
||||||
ir_fingerprint: String::new(),
|
|
||||||
})
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"));
|
|
||||||
}
|
|
||||||
let digest = crank_import::rest::SourceDigest::parse(digest)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let ir = crank_import::rest::normalize_verified_bundle(
|
|
||||||
document,
|
|
||||||
digest,
|
|
||||||
&snapshots,
|
|
||||||
&normalization_config,
|
|
||||||
)
|
|
||||||
.map_err(|error| match error {
|
|
||||||
crank_import::rest::ImportParseError::NoMethods => {
|
|
||||||
ApiError::openapi_upload(locale, "no_methods")
|
|
||||||
}
|
|
||||||
_ => ApiError::openapi_upload(locale, "invalid_document"),
|
|
||||||
})?;
|
|
||||||
let ir_fingerprint = preview_digest(
|
|
||||||
&serde_json::to_value(&ir)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))?,
|
|
||||||
)?;
|
|
||||||
Ok::<_, ApiError>(ParsedOpenApiPreview {
|
|
||||||
preview: crank_import::rest::preview_from_ir(&ir),
|
|
||||||
ir_fingerprint,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let remaining = OPENAPI_PARSE_DEADLINE
|
|
||||||
.checked_sub(started.elapsed())
|
|
||||||
.unwrap_or(std::time::Duration::ZERO);
|
|
||||||
tokio::time::timeout(remaining, parsing)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn artifact_error(locale: OpenApiUploadLocale, error: ArtifactError) -> ApiError {
|
|
||||||
match error {
|
|
||||||
ArtifactError::EmptySource => ApiError::openapi_upload(locale, "empty_file"),
|
|
||||||
ArtifactError::SourceTooLarge => ApiError::openapi_upload(locale, "file_too_large"),
|
|
||||||
ArtifactError::Integrity => ApiError::openapi_upload(locale, "source_integrity"),
|
|
||||||
ArtifactError::Storage
|
|
||||||
| ArtifactError::NotFound
|
|
||||||
| ArtifactError::InvalidReference
|
|
||||||
| ArtifactError::UnsafeRoot => ApiError::openapi_upload(locale, "storage_unavailable"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn openapi_source_error(locale: OpenApiUploadLocale, error: RegistryError) -> ApiError {
|
|
||||||
match error {
|
|
||||||
RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable => {
|
|
||||||
ApiError::openapi_upload(locale, "source_unavailable")
|
|
||||||
}
|
|
||||||
RegistryError::SourceIntegrity => ApiError::openapi_upload(locale, "source_integrity"),
|
|
||||||
other => ApiError::from(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preview_digest(preview: &serde_json::Value) -> Result<String, ApiError> {
|
|
||||||
let canonical =
|
|
||||||
serde_json::to_vec(preview).map_err(|error| ApiError::internal(error.to_string()))?;
|
|
||||||
Ok(format!("{:x}", Sha256::digest(canonical)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_preview_contract(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
parsed: &ParsedOpenApiPreview,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
// Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the
|
|
||||||
// old reparse behavior; new jobs fail closed if parser output drifts or a
|
|
||||||
// persisted preview has been changed.
|
|
||||||
let expected = payload
|
|
||||||
.get("preview_digest")
|
|
||||||
.and_then(serde_json::Value::as_str);
|
|
||||||
if payload.get("normalization").is_some() && expected.is_none() {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let Some(expected) = expected else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let actual = preview_digest(
|
|
||||||
&serde_json::to_value(&parsed.preview)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
)?;
|
|
||||||
if actual == expected {
|
|
||||||
if let Some(normalization) = payload.get("normalization") {
|
|
||||||
let normalizer = normalization
|
|
||||||
.get("normalizer_version")
|
|
||||||
.and_then(serde_json::Value::as_str);
|
|
||||||
let projection = normalization
|
|
||||||
.get("projection_version")
|
|
||||||
.and_then(serde_json::Value::as_str);
|
|
||||||
let fingerprint = normalization
|
|
||||||
.get("ir_fingerprint")
|
|
||||||
.and_then(serde_json::Value::as_str);
|
|
||||||
if normalizer != Some(NORMALIZER_VERSION)
|
|
||||||
|| projection != Some(PROJECTION_VERSION)
|
|
||||||
|| fingerprint != Some(parsed.ir_fingerprint.as_str())
|
|
||||||
{
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::openapi_upload(locale, "source_integrity"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preview_has_blocker(
|
|
||||||
preview: &crank_import::rest::ImportPreview,
|
|
||||||
selected_operation_keys: &BTreeSet<String>,
|
|
||||||
) -> bool {
|
|
||||||
preview
|
|
||||||
.findings
|
|
||||||
.iter()
|
|
||||||
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|
|
||||||
|| preview
|
|
||||||
.groups
|
|
||||||
.iter()
|
|
||||||
.flat_map(|group| group.operations.iter())
|
|
||||||
.filter(|operation| selected_operation_keys.contains(&operation.key))
|
|
||||||
.flat_map(|operation| operation.findings.iter())
|
|
||||||
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
|
|
||||||
let selected_operation_keys = payload
|
|
||||||
.selected_operation_keys
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
let canonical = serde_json::to_vec(&json!({
|
|
||||||
"selected_operation_keys": selected_operation_keys,
|
|
||||||
"server_url": payload.server_url.as_deref(),
|
|
||||||
"conflict_mode": payload.conflict_mode.as_str(),
|
|
||||||
}))
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
|
||||||
Ok(format!("{:x}", Sha256::digest(canonical)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_import_findings(
|
|
||||||
draft: &mut crank_import::rest::RestImportCandidate,
|
|
||||||
candidate: &ImportOperationCandidate,
|
|
||||||
) {
|
|
||||||
let findings = candidate
|
|
||||||
.findings
|
|
||||||
.iter()
|
|
||||||
.map(tool_quality_finding_from_import)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if findings.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut wizard_state = draft.wizard_state.take().unwrap_or_default();
|
|
||||||
wizard_state.import_findings = findings;
|
|
||||||
draft.wizard_state = Some(wizard_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_quality_finding_from_import(finding: &ImportFinding) -> ToolQualityFinding {
|
|
||||||
ToolQualityFinding {
|
|
||||||
severity: match finding.severity {
|
|
||||||
ImportFindingSeverity::Info => ToolQualitySeverity::Info,
|
|
||||||
ImportFindingSeverity::Warning => ToolQualitySeverity::Warning,
|
|
||||||
ImportFindingSeverity::Error => ToolQualitySeverity::Error,
|
|
||||||
},
|
|
||||||
code: format!("openapi_import.{}", finding.code),
|
|
||||||
message: finding.message.clone(),
|
|
||||||
suggested_action: Some(
|
|
||||||
"Откройте черновик в мастере и уточните описание, схемы или маппинг перед публикацией."
|
|
||||||
.to_owned(),
|
|
||||||
),
|
|
||||||
field_path: finding.operation_key.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
pub(super) struct MaterializedDependencies {
|
|
||||||
pub(super) snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
|
|
||||||
envelopes: Vec<ImportJobSourceEnvelope>,
|
|
||||||
canonical_uris: Vec<String>,
|
|
||||||
guards: Vec<SourceDetachGuard>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct ImportReplayContext<'a> {
|
|
||||||
pub(super) workspace_id: &'a WorkspaceId,
|
|
||||||
pub(super) job_id: &'a ImportJobId,
|
|
||||||
pub(super) application_key: &'a str,
|
|
||||||
pub(super) conflict_mode: ImportConflictMode,
|
|
||||||
pub(super) finished_at: &'a OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterializedDependencies {
|
|
||||||
fn empty() -> Self {
|
|
||||||
Self {
|
|
||||||
snapshots: Vec::new(),
|
|
||||||
envelopes: Vec::new(),
|
|
||||||
canonical_uris: Vec::new(),
|
|
||||||
guards: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn payload(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.envelopes
|
|
||||||
.iter()
|
|
||||||
.zip(&self.canonical_uris)
|
|
||||||
.map(|(dependency, canonical_uri)| {
|
|
||||||
json!({
|
|
||||||
"source_id": dependency.source_id.as_str(),
|
|
||||||
"digest": dependency.digest.as_str(),
|
|
||||||
"canonical_uri": canonical_uri,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn snapshot_payload(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.snapshots
|
|
||||||
.iter()
|
|
||||||
.filter_map(|snapshot| {
|
|
||||||
self.envelopes
|
|
||||||
.iter()
|
|
||||||
.find(|dependency| dependency.digest.digest_hex() == snapshot.digest.as_str())
|
|
||||||
.map(|dependency| {
|
|
||||||
json!({
|
|
||||||
"source_id": dependency.source_id.as_str(),
|
|
||||||
"digest": dependency.digest.as_str(),
|
|
||||||
"canonical_uri": snapshot.canonical_uri,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn disarm(&mut self) {
|
|
||||||
for guard in &mut self.guards {
|
|
||||||
guard.disarm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn detach_all(&mut self) {
|
|
||||||
for guard in &mut self.guards {
|
|
||||||
guard.detach_now().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub(super) async fn fail_openapi_import_or_replay(
|
|
||||||
&self,
|
|
||||||
context: &ImportReplayContext<'_>,
|
|
||||||
error_text: &'static str,
|
|
||||||
error: ApiError,
|
|
||||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
|
||||||
let empty = json!([]);
|
|
||||||
self.registry
|
|
||||||
.finish_import_job(FinishImportJobRequest {
|
|
||||||
id: context.job_id,
|
|
||||||
status: ImportJobStatus::Failed,
|
|
||||||
created_operation_ids: &empty,
|
|
||||||
error_text: Some(error_text),
|
|
||||||
finished_at: context.finished_at,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// `finish_import_job` preserves an already Completed row under its
|
|
||||||
// lock. This read distinguishes that race from the terminal Failed
|
|
||||||
// transition and returns the immutable canonical application result.
|
|
||||||
let latest = self
|
|
||||||
.registry
|
|
||||||
.get_import_job(context.workspace_id, context.job_id)
|
|
||||||
.await?;
|
|
||||||
if latest.is_some_and(|job| job.status == ImportJobStatus::Completed) {
|
|
||||||
let applied = self
|
|
||||||
.registry
|
|
||||||
.apply_import_job(ApplyImportJobRequest {
|
|
||||||
id: context.job_id,
|
|
||||||
workspace_id: context.workspace_id,
|
|
||||||
application_key: context.application_key,
|
|
||||||
conflict_mode: context.conflict_mode,
|
|
||||||
operations: &[],
|
|
||||||
pre_skipped: &[],
|
|
||||||
finished_at: context.finished_at,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
return Ok(openapi_import_response(applied));
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn materialize_external_reference_snapshots(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
primary_bytes: &[u8],
|
|
||||||
created_at: OffsetDateTime,
|
|
||||||
) -> MaterializedDependencies {
|
|
||||||
let materialized_count = std::sync::atomic::AtomicUsize::new(0);
|
|
||||||
match tokio::time::timeout(
|
|
||||||
self.external_reference_materialization_timeout,
|
|
||||||
self.materialize_external_reference_snapshots_inner(
|
|
||||||
workspace_id,
|
|
||||||
primary_bytes,
|
|
||||||
created_at,
|
|
||||||
&materialized_count,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(dependencies) => dependencies,
|
|
||||||
Err(_) => {
|
|
||||||
materialization_failure(
|
|
||||||
"chain",
|
|
||||||
"timeout",
|
|
||||||
materialized_count.load(std::sync::atomic::Ordering::Relaxed),
|
|
||||||
);
|
|
||||||
MaterializedDependencies::empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn materialize_external_reference_snapshots_inner(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
primary_bytes: &[u8],
|
|
||||||
created_at: OffsetDateTime,
|
|
||||||
materialized_count: &std::sync::atomic::AtomicUsize,
|
|
||||||
) -> MaterializedDependencies {
|
|
||||||
if !self
|
|
||||||
.external_reference_normalization
|
|
||||||
.external_references_enabled
|
|
||||||
{
|
|
||||||
return MaterializedDependencies::empty();
|
|
||||||
}
|
|
||||||
let Ok(primary) = std::str::from_utf8(primary_bytes) else {
|
|
||||||
materialization_failure("primary_decode", "invalid_utf8", 0);
|
|
||||||
return MaterializedDependencies::empty();
|
|
||||||
};
|
|
||||||
let Ok(primary_references) =
|
|
||||||
crank_import::rest::reference_uris(primary, &self.external_reference_normalization)
|
|
||||||
else {
|
|
||||||
materialization_failure("primary_scan", "invalid_document", 0);
|
|
||||||
return MaterializedDependencies::empty();
|
|
||||||
};
|
|
||||||
let mut queue = VecDeque::new();
|
|
||||||
for reference in primary_references {
|
|
||||||
if let Some(uri) = canonical_external_document_uri(None, &reference) {
|
|
||||||
queue.push_back((uri, 1usize));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut seen = BTreeSet::new();
|
|
||||||
let mut result = MaterializedDependencies::empty();
|
|
||||||
let mut dependency_by_digest = BTreeMap::<String, usize>::new();
|
|
||||||
while let Some((canonical_uri, depth)) = queue.pop_front() {
|
|
||||||
if !seen.insert(canonical_uri.clone())
|
|
||||||
|| depth > self.external_reference_normalization.max_reference_depth
|
|
||||||
|| seen.len()
|
|
||||||
> self
|
|
||||||
.external_reference_normalization
|
|
||||||
.max_reference_documents
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let bytes = match self.external_reference_fetcher.get(&canonical_uri).await {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(error) => {
|
|
||||||
materialization_failure(
|
|
||||||
"fetch",
|
|
||||||
external_fetch_error_code(&error),
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Ok(document) = std::str::from_utf8(&bytes).map(str::to_owned) else {
|
|
||||||
materialization_failure(
|
|
||||||
"dependency_decode",
|
|
||||||
"invalid_utf8",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(references) = crank_import::rest::external_reference_uris(
|
|
||||||
&document,
|
|
||||||
&self.external_reference_normalization,
|
|
||||||
) else {
|
|
||||||
materialization_failure(
|
|
||||||
"dependency_scan",
|
|
||||||
"invalid_document",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let store = self.artifact_store.clone();
|
|
||||||
let artifact_bytes = bytes.clone();
|
|
||||||
let Ok(Ok(artifact)) =
|
|
||||||
tokio::task::spawn_blocking(move || store.put_registered(&artifact_bytes)).await
|
|
||||||
else {
|
|
||||||
materialization_failure(
|
|
||||||
"artifact_store",
|
|
||||||
"storage_unavailable",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let digest_key = artifact.artifact_ref().as_str().to_owned();
|
|
||||||
let envelope_index = if let Some(index) = dependency_by_digest.get(&digest_key) {
|
|
||||||
*index
|
|
||||||
} else {
|
|
||||||
let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi_dep"));
|
|
||||||
let Ok(source) = self
|
|
||||||
.registry
|
|
||||||
.create_artifact_source(CreateArtifactSourceRequest {
|
|
||||||
workspace_id,
|
|
||||||
source_id: &source_id,
|
|
||||||
artifact: &artifact,
|
|
||||||
mime_type: "application/octet-stream",
|
|
||||||
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
||||||
created_at,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
materialization_failure(
|
|
||||||
"source_create",
|
|
||||||
"registry_unavailable",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut guard = SourceDetachGuard::new(
|
|
||||||
self.registry.clone(),
|
|
||||||
workspace_id.clone(),
|
|
||||||
source_id.clone(),
|
|
||||||
source.updated_at,
|
|
||||||
);
|
|
||||||
let Ok(verified) = self
|
|
||||||
.registry
|
|
||||||
.read_artifact_source(
|
|
||||||
std::sync::Arc::clone(&self.artifact_store),
|
|
||||||
workspace_id,
|
|
||||||
&source_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
materialization_failure(
|
|
||||||
"source_verify",
|
|
||||||
"source_unavailable",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
guard.detach_now().await;
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if verified.source.blob.artifact_ref != *artifact.artifact_ref() {
|
|
||||||
materialization_failure(
|
|
||||||
"source_verify",
|
|
||||||
"source_integrity",
|
|
||||||
result.snapshots.len(),
|
|
||||||
);
|
|
||||||
guard.detach_now().await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let index = result.envelopes.len();
|
|
||||||
result.envelopes.push(ImportJobSourceEnvelope {
|
|
||||||
source_id: source_id.clone(),
|
|
||||||
digest: artifact.artifact_ref().clone(),
|
|
||||||
});
|
|
||||||
result.guards.push(guard);
|
|
||||||
dependency_by_digest.insert(digest_key, index);
|
|
||||||
index
|
|
||||||
};
|
|
||||||
let dependency = &result.envelopes[envelope_index];
|
|
||||||
let snapshot_digest = match crank_import::rest::SourceDigest::parse(
|
|
||||||
dependency.digest.digest_hex().to_owned(),
|
|
||||||
) {
|
|
||||||
Ok(digest) => digest,
|
|
||||||
Err(_) => {
|
|
||||||
materialization_failure("snapshot", "source_integrity", result.snapshots.len());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
result
|
|
||||||
.snapshots
|
|
||||||
.push(crank_import::rest::ExternalDocumentSnapshot {
|
|
||||||
canonical_uri: canonical_uri.clone(),
|
|
||||||
digest: snapshot_digest,
|
|
||||||
document,
|
|
||||||
});
|
|
||||||
materialized_count.store(result.snapshots.len(), std::sync::atomic::Ordering::Relaxed);
|
|
||||||
result.canonical_uris.push(canonical_uri.clone());
|
|
||||||
for reference in references {
|
|
||||||
if let Some(uri) = canonical_external_document_uri(Some(&canonical_uri), &reference)
|
|
||||||
{
|
|
||||||
queue.push_back((uri, depth.saturating_add(1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Registry ownership is one envelope per immutable digest, while the
|
|
||||||
// resolver may map several canonical URIs to that same snapshot. Keep
|
|
||||||
// payload rows aligned with envelopes and add aliases separately.
|
|
||||||
if result.envelopes.len() != result.canonical_uris.len() {
|
|
||||||
let mut canonical_by_digest = BTreeMap::new();
|
|
||||||
for snapshot in &result.snapshots {
|
|
||||||
canonical_by_digest
|
|
||||||
.entry(snapshot.digest.as_str().to_owned())
|
|
||||||
.or_insert_with(|| snapshot.canonical_uri.clone());
|
|
||||||
}
|
|
||||||
result.canonical_uris = result
|
|
||||||
.envelopes
|
|
||||||
.iter()
|
|
||||||
.map(|envelope| {
|
|
||||||
canonical_by_digest
|
|
||||||
.get(envelope.digest.digest_hex())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn read_import_job_dependencies(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<Vec<crank_import::rest::ExternalDocumentSnapshot>, ApiError> {
|
|
||||||
let dependencies = import_job_dependencies(payload, locale)?;
|
|
||||||
let mut snapshots = Vec::with_capacity(dependencies.len());
|
|
||||||
for (canonical_uri, dependency) in dependencies {
|
|
||||||
let verified = self
|
|
||||||
.registry
|
|
||||||
.read_artifact_source(
|
|
||||||
std::sync::Arc::clone(&self.artifact_store),
|
|
||||||
workspace_id,
|
|
||||||
&dependency.source_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| openapi_source_error(locale, error))?;
|
|
||||||
if verified.source.blob.artifact_ref != dependency.digest {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let document = String::from_utf8(verified.bytes)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let digest =
|
|
||||||
crank_import::rest::SourceDigest::parse(dependency.digest.digest_hex().to_owned())
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
snapshots.push(crank_import::rest::ExternalDocumentSnapshot {
|
|
||||||
canonical_uri,
|
|
||||||
digest,
|
|
||||||
document,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(snapshots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn external_fetch_error_code(error: &crank_runtime::ExternalReferenceFetchError) -> &'static str {
|
|
||||||
match error {
|
|
||||||
crank_runtime::ExternalReferenceFetchError::Disabled => "disabled",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::InvalidUrl => "invalid_url",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::TargetNotAllowed => "target_not_allowed",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::RedirectNotAllowed => "redirect_not_allowed",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::ResponseTooLarge { .. } => "response_too_large",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::UnexpectedStatus { .. } => "unexpected_status",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::Transport { timeout: true, .. } => "timeout",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::Transport { .. } => "transport",
|
|
||||||
crank_runtime::ExternalReferenceFetchError::InvalidConfiguration => "invalid_configuration",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn materialization_failure(stage: &'static str, error_code: &'static str, count: usize) {
|
|
||||||
warn!(
|
|
||||||
name: "admin.openapi_import.materialization_failed",
|
|
||||||
stage,
|
|
||||||
error_code,
|
|
||||||
count,
|
|
||||||
"external OpenAPI materialization failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::external_fetch_error_code;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unexpected_status_has_a_stable_safe_error_code() {
|
|
||||||
let error = crank_runtime::ExternalReferenceFetchError::UnexpectedStatus { status: 500 };
|
|
||||||
assert_eq!(external_fetch_error_code(&error), "unexpected_status");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use crank_core::WorkspaceId;
|
|
||||||
use crank_registry::{ArtifactSourceId, DetachArtifactSourceRequest, ImportJobSourceEnvelope};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
use crate::{error::ApiError, service::OpenApiUploadLocale};
|
|
||||||
|
|
||||||
use super::canonical_external_document_uri;
|
|
||||||
|
|
||||||
const MAX_IMPORT_JOB_DOCUMENTS: usize = 32;
|
|
||||||
|
|
||||||
pub(super) fn import_job_source(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<ImportJobSourceEnvelope, ApiError> {
|
|
||||||
let source = payload
|
|
||||||
.get("source")
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
|
||||||
let source_id = source
|
|
||||||
.get("source_id")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| value.len() <= 132)
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
|
||||||
let digest = source
|
|
||||||
.get("digest")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.and_then(|value| value.parse().ok())
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
Ok(ImportJobSourceEnvelope {
|
|
||||||
source_id: ArtifactSourceId::new(source_id),
|
|
||||||
digest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn import_job_dependencies(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<Vec<(String, ImportJobSourceEnvelope)>, ApiError> {
|
|
||||||
let empty = Vec::new();
|
|
||||||
let items = payload
|
|
||||||
.get("dependency_snapshots")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.unwrap_or(&empty);
|
|
||||||
if items.len() > MAX_IMPORT_JOB_DOCUMENTS {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let mut canonical_uris = BTreeSet::new();
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|item| {
|
|
||||||
let canonical_uri = item
|
|
||||||
.get("canonical_uri")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| {
|
|
||||||
canonical_external_document_uri(None, value).as_deref() == Some(*value)
|
|
||||||
})
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
if !canonical_uris.insert(canonical_uri.to_owned()) {
|
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
|
||||||
}
|
|
||||||
let source_id = item
|
|
||||||
.get("source_id")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| value.len() <= 132)
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
let digest = item
|
|
||||||
.get("digest")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.and_then(|value| value.parse().ok())
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
|
||||||
Ok((
|
|
||||||
canonical_uri.to_owned(),
|
|
||||||
ImportJobSourceEnvelope {
|
|
||||||
source_id: ArtifactSourceId::new(source_id),
|
|
||||||
digest,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn import_job_normalization_config(
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
current: &crank_import::rest::NormalizationConfig,
|
|
||||||
legacy_v1: bool,
|
|
||||||
locale: OpenApiUploadLocale,
|
|
||||||
) -> Result<crank_import::rest::NormalizationConfig, ApiError> {
|
|
||||||
if legacy_v1 {
|
|
||||||
return Ok(current.clone());
|
|
||||||
}
|
|
||||||
payload
|
|
||||||
.pointer("/normalization/config")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))
|
|
||||||
.and_then(|value| {
|
|
||||||
serde_json::from_value(value)
|
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct SourceDetachGuard {
|
|
||||||
registry: crank_registry::PostgresRegistry,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
source_id: ArtifactSourceId,
|
|
||||||
expected_updated_at: OffsetDateTime,
|
|
||||||
armed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SourceDetachGuard {
|
|
||||||
pub(super) fn new(
|
|
||||||
registry: crank_registry::PostgresRegistry,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
source_id: ArtifactSourceId,
|
|
||||||
expected_updated_at: OffsetDateTime,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
registry,
|
|
||||||
workspace_id,
|
|
||||||
source_id,
|
|
||||||
expected_updated_at,
|
|
||||||
armed: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn disarm(&mut self) {
|
|
||||||
self.armed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn detach_now(&mut self) {
|
|
||||||
if !self.armed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.armed = false;
|
|
||||||
let _ = self
|
|
||||||
.registry
|
|
||||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
||||||
workspace_id: &self.workspace_id,
|
|
||||||
source_id: &self.source_id,
|
|
||||||
expected_updated_at: Some(self.expected_updated_at),
|
|
||||||
detached_at: OffsetDateTime::now_utc(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SourceDetachGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if !self.armed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let registry = self.registry.clone();
|
|
||||||
let workspace_id = self.workspace_id.clone();
|
|
||||||
let source_id = self.source_id.clone();
|
|
||||||
let expected_updated_at = Some(self.expected_updated_at);
|
|
||||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
||||||
handle.spawn(async move {
|
|
||||||
let _ = registry
|
|
||||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
||||||
workspace_id: &workspace_id,
|
|
||||||
source_id: &source_id,
|
|
||||||
expected_updated_at,
|
|
||||||
detached_at: OffsetDateTime::now_utc(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,690 +0,0 @@
|
|||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
||||||
use crank_core::{
|
|
||||||
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
|
|
||||||
WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord,
|
|
||||||
InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery,
|
|
||||||
UsageRollupRecord,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, ApprovalDecisionPayload, ApprovalsQuery, LogsListResponse, LogsQuery,
|
|
||||||
UsageOverviewResponse, UsageRequestQuery, usage_window,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const LOG_LIST_DEFAULT_LIMIT: u32 = 100;
|
|
||||||
const LOG_LIST_MAX_LIMIT: u32 = 200;
|
|
||||||
const LOG_CSV_MAX_ROWS: u32 = 1_000;
|
|
||||||
const MAX_EXPLICIT_USAGE_WINDOW_DAYS: i64 = 90;
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct LogsCursor {
|
|
||||||
created_at: String,
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord {
|
|
||||||
record.approval.request_payload =
|
|
||||||
crank_core::sanitize_invocation_preview(&record.approval.request_payload);
|
|
||||||
record.approval.response_payload = record
|
|
||||||
.approval
|
|
||||||
.response_payload
|
|
||||||
.as_ref()
|
|
||||||
.map(crank_core::sanitize_invocation_preview);
|
|
||||||
record
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub async fn cleanup_invocation_logs_before(
|
|
||||||
&self,
|
|
||||||
cutoff: OffsetDateTime,
|
|
||||||
) -> Result<InvocationRetentionOutcome, ApiError> {
|
|
||||||
self.registry
|
|
||||||
.delete_invocation_logs_before(cutoff)
|
|
||||||
.await
|
|
||||||
.map_err(ApiError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn list_logs(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
query: LogsQuery,
|
|
||||||
) -> Result<LogsListResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let operation_id = query.operation_id.as_deref().map(OperationId::new);
|
|
||||||
let agent_id = query.agent_id.as_deref().map(AgentId::new);
|
|
||||||
let (_, created_after, created_before, _) =
|
|
||||||
resolve_usage_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
|
||||||
let (cursor_created_at, cursor_id) = decode_logs_cursor(query.cursor.as_deref())?;
|
|
||||||
let limit = query
|
|
||||||
.limit
|
|
||||||
.unwrap_or(LOG_LIST_DEFAULT_LIMIT)
|
|
||||||
.clamp(1, LOG_LIST_MAX_LIMIT);
|
|
||||||
|
|
||||||
let mut items = self
|
|
||||||
.registry
|
|
||||||
.list_invocation_logs(ListInvocationLogsQuery {
|
|
||||||
workspace_id,
|
|
||||||
level: query.level,
|
|
||||||
status: query.status,
|
|
||||||
outcome_group: query.outcome_group,
|
|
||||||
search_text: query.search.as_deref(),
|
|
||||||
source: query.source,
|
|
||||||
operation_id: operation_id.as_ref(),
|
|
||||||
agent_id: agent_id.as_ref(),
|
|
||||||
created_after: Some(&created_after),
|
|
||||||
created_before: Some(&created_before),
|
|
||||||
cursor_created_at: cursor_created_at.as_deref(),
|
|
||||||
cursor_id: cursor_id.as_ref(),
|
|
||||||
limit: limit.saturating_add(1),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let next_cursor = if items.len() > limit as usize {
|
|
||||||
items.truncate(limit as usize);
|
|
||||||
items.last().map(encode_logs_cursor).transpose()?
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(LogsListResponse { items, next_cursor })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_log(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
log_id: &InvocationLogId,
|
|
||||||
) -> Result<InvocationLogRecord, ApiError> {
|
|
||||||
self.registry
|
|
||||||
.get_invocation_log(workspace_id, log_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("invocation log {} was not found", log_id.as_str()),
|
|
||||||
json!({ "log_id": log_id.as_str() }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn export_logs_csv(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
mut query: LogsQuery,
|
|
||||||
) -> Result<String, ApiError> {
|
|
||||||
query.limit = Some(
|
|
||||||
query
|
|
||||||
.limit
|
|
||||||
.unwrap_or(LOG_CSV_MAX_ROWS)
|
|
||||||
.clamp(1, LOG_CSV_MAX_ROWS),
|
|
||||||
);
|
|
||||||
query.cursor = None;
|
|
||||||
let page = self.list_logs(workspace_id, query).await?;
|
|
||||||
let mut csv = String::from(
|
|
||||||
"created_at,level,status,source,agent,operation,operation_version,duration_ms,request_id,trace_id,stage,error_code,message,request_preview,response_preview\n",
|
|
||||||
);
|
|
||||||
for record in page.items {
|
|
||||||
let log = record.log;
|
|
||||||
let row = [
|
|
||||||
log.created_at
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
serialize_csv_text(log.level)?,
|
|
||||||
serialize_csv_text(log.status)?,
|
|
||||||
serialize_csv_text(log.source)?,
|
|
||||||
record
|
|
||||||
.agent_display_name
|
|
||||||
.or(record.agent_slug)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
record.operation_display_name,
|
|
||||||
log.operation_version
|
|
||||||
.map(|value| value.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
log.duration_ms.to_string(),
|
|
||||||
log.request_id.unwrap_or_default(),
|
|
||||||
log.trace_id.unwrap_or_default(),
|
|
||||||
log.execution_stage
|
|
||||||
.map(serialize_csv_text)
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
log.execution_error_code
|
|
||||||
.map(serialize_csv_text)
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
log.message,
|
|
||||||
serde_json::to_string(&log.request_preview)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
serde_json::to_string(&log.response_preview)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
];
|
|
||||||
csv.push_str(&row.into_iter().map(csv_cell).collect::<Vec<_>>().join(","));
|
|
||||||
csv.push('\n');
|
|
||||||
if csv.len() > 1_048_576 {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"logs CSV export exceeded the bounded response size",
|
|
||||||
json!({ "error_code": "logs_csv_too_large" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(csv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn list_approvals(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
query: ApprovalsQuery,
|
|
||||||
) -> Result<Vec<ApprovalRequestRecord>, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let records = self
|
|
||||||
.registry
|
|
||||||
.list_approval_requests(ListApprovalRequestsQuery {
|
|
||||||
workspace_id,
|
|
||||||
status: query.status,
|
|
||||||
limit: query.limit.unwrap_or(50).clamp(1, 200),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut normalized = Vec::with_capacity(records.len());
|
|
||||||
for record in records {
|
|
||||||
let record = self.normalize_approval_record(record).await?;
|
|
||||||
if query.status.is_none() || record.approval.status == query.status.unwrap() {
|
|
||||||
normalized.push(safe_approval_record(record));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_approval(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
approval_id: &ApprovalRequestId,
|
|
||||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let record = self
|
|
||||||
.registry
|
|
||||||
.get_approval_request(workspace_id, approval_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("approval request {} was not found", approval_id.as_str()),
|
|
||||||
json!({ "approval_id": approval_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
self.normalize_approval_record(record)
|
|
||||||
.await
|
|
||||||
.map(safe_approval_record)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload))]
|
|
||||||
pub async fn approve_approval(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
approval_id: &ApprovalRequestId,
|
|
||||||
payload: ApprovalDecisionPayload,
|
|
||||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
|
||||||
if !payload.approve.eq_ignore_ascii_case("yes") {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"approve must be yes for approval confirmation",
|
|
||||||
json!({ "error_code": "invalid_decision_payload" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.decide_admin_approval(
|
|
||||||
workspace_id,
|
|
||||||
approval_id,
|
|
||||||
ApprovalRequestStatus::Approved,
|
|
||||||
Some(json!({"approve": "yes"})),
|
|
||||||
payload.note.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self, payload))]
|
|
||||||
pub async fn deny_approval(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
approval_id: &ApprovalRequestId,
|
|
||||||
payload: ApprovalDecisionPayload,
|
|
||||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
|
||||||
if !payload.approve.eq_ignore_ascii_case("no") {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"approve must be no for approval denial",
|
|
||||||
json!({ "error_code": "invalid_decision_payload" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.decide_admin_approval(
|
|
||||||
workspace_id,
|
|
||||||
approval_id,
|
|
||||||
ApprovalRequestStatus::Denied,
|
|
||||||
Some(json!({"approve": "no"})),
|
|
||||||
payload.note.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn decide_admin_approval(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
approval_id: &ApprovalRequestId,
|
|
||||||
status: ApprovalRequestStatus,
|
|
||||||
response_payload: Option<serde_json::Value>,
|
|
||||||
decision_note: Option<&str>,
|
|
||||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let current = self
|
|
||||||
.registry
|
|
||||||
.get_approval_request(workspace_id, approval_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("approval request {} was not found", approval_id.as_str()),
|
|
||||||
json!({ "approval_id": approval_id.as_str() }),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let normalized = self.normalize_approval_record(current).await?;
|
|
||||||
if normalized.approval.status != ApprovalRequestStatus::Pending {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"approval request is not pending",
|
|
||||||
json!({
|
|
||||||
"approval_id": approval_id.as_str(),
|
|
||||||
"status": normalized.approval.status,
|
|
||||||
"error_code": "approval_state_conflict"
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let decided = self
|
|
||||||
.registry
|
|
||||||
.decide_approval_request(DecideApprovalRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id: &normalized.approval.agent_id,
|
|
||||||
approval_id,
|
|
||||||
operation_id: &normalized.approval.operation_id,
|
|
||||||
operation_version: normalized.approval.operation_version,
|
|
||||||
request_payload: &normalized.approval.request_payload,
|
|
||||||
status,
|
|
||||||
decided_at: OffsetDateTime::now_utc(),
|
|
||||||
decided_by_key_id: None,
|
|
||||||
response_payload,
|
|
||||||
decision_note,
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::conflict_with_context(
|
|
||||||
"approval request state changed before decision was recorded",
|
|
||||||
json!({
|
|
||||||
"approval_id": approval_id.as_str(),
|
|
||||||
"error_code": "approval_state_conflict"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(safe_approval_record(decided))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn normalize_approval_record(
|
|
||||||
&self,
|
|
||||||
record: ApprovalRequestRecord,
|
|
||||||
) -> Result<ApprovalRequestRecord, ApiError> {
|
|
||||||
if record.approval.status != ApprovalRequestStatus::Pending
|
|
||||||
|| record.approval.expires_at > OffsetDateTime::now_utc()
|
|
||||||
{
|
|
||||||
return Ok(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(self
|
|
||||||
.registry
|
|
||||||
.expire_approval_request(ExpireApprovalRequest {
|
|
||||||
workspace_id: &record.approval.workspace_id,
|
|
||||||
agent_id: &record.approval.agent_id,
|
|
||||||
approval_id: &record.approval.id,
|
|
||||||
expired_at: OffsetDateTime::now_utc(),
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.unwrap_or(record))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_usage_overview(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
query: UsageRequestQuery,
|
|
||||||
) -> Result<UsageOverviewResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let (period, created_after, created_before, bucket) =
|
|
||||||
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
|
||||||
let usage_query = UsageQuery {
|
|
||||||
workspace_id,
|
|
||||||
period,
|
|
||||||
source: query.source,
|
|
||||||
created_after: &created_after,
|
|
||||||
created_before: &created_before,
|
|
||||||
bucket,
|
|
||||||
};
|
|
||||||
|
|
||||||
let summary = self.registry.summarize_usage(usage_query.clone()).await?;
|
|
||||||
let timeline = self
|
|
||||||
.registry
|
|
||||||
.list_usage_timeline(usage_query.clone())
|
|
||||||
.await?;
|
|
||||||
let operations = self
|
|
||||||
.registry
|
|
||||||
.list_usage_by_operation(usage_query.clone())
|
|
||||||
.await?;
|
|
||||||
let agents = self
|
|
||||||
.registry
|
|
||||||
.list_usage_by_agent(usage_query.clone())
|
|
||||||
.await?;
|
|
||||||
let outcomes = self.registry.list_usage_outcomes(usage_query).await?;
|
|
||||||
|
|
||||||
Ok(UsageOverviewResponse {
|
|
||||||
summary,
|
|
||||||
timeline,
|
|
||||||
operations,
|
|
||||||
agents,
|
|
||||||
outcomes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn export_usage_csv(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
query: UsageRequestQuery,
|
|
||||||
) -> Result<String, ApiError> {
|
|
||||||
let usage = self.get_usage_overview(workspace_id, query).await?;
|
|
||||||
let mut csv = String::from(
|
|
||||||
"kind,name,group,error_code,calls_total,calls_error,p50_ms,p95_ms,p99_ms\n",
|
|
||||||
);
|
|
||||||
for operation in usage.operations {
|
|
||||||
csv.push_str(
|
|
||||||
&[
|
|
||||||
"operation".to_owned(),
|
|
||||||
operation.operation_display_name,
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
operation.calls_total.to_string(),
|
|
||||||
operation.calls_error.to_string(),
|
|
||||||
operation.p50_ms.to_string(),
|
|
||||||
operation.p95_ms.to_string(),
|
|
||||||
operation.p99_ms.to_string(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(csv_cell)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
);
|
|
||||||
csv.push('\n');
|
|
||||||
}
|
|
||||||
for agent in usage.agents {
|
|
||||||
csv.push_str(
|
|
||||||
&[
|
|
||||||
"agent".to_owned(),
|
|
||||||
agent.agent_display_name,
|
|
||||||
String::new(),
|
|
||||||
String::new(),
|
|
||||||
agent.calls_total.to_string(),
|
|
||||||
agent.calls_error.to_string(),
|
|
||||||
agent.p50_ms.to_string(),
|
|
||||||
agent.p95_ms.to_string(),
|
|
||||||
agent.p99_ms.to_string(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(csv_cell)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
);
|
|
||||||
csv.push('\n');
|
|
||||||
}
|
|
||||||
for outcome in usage.outcomes {
|
|
||||||
csv.push_str(
|
|
||||||
&[
|
|
||||||
"outcome".to_owned(),
|
|
||||||
String::new(),
|
|
||||||
serialize_csv_text(outcome.group)?,
|
|
||||||
outcome
|
|
||||||
.execution_error_code
|
|
||||||
.map(serialize_csv_text)
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
outcome.calls_total.to_string(),
|
|
||||||
String::new(),
|
|
||||||
outcome.p50_ms.to_string(),
|
|
||||||
outcome.p95_ms.to_string(),
|
|
||||||
outcome.p99_ms.to_string(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(csv_cell)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
);
|
|
||||||
csv.push('\n');
|
|
||||||
}
|
|
||||||
if csv.len() > 1_048_576 {
|
|
||||||
return Err(ApiError::unprocessable_with_context(
|
|
||||||
"usage CSV export exceeded the bounded response size",
|
|
||||||
json!({ "error_code": "usage_csv_too_large" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(csv)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_operation_usage(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
operation_id: &OperationId,
|
|
||||||
query: UsageRequestQuery,
|
|
||||||
) -> Result<UsageRollupRecord, ApiError> {
|
|
||||||
self.get_operation(workspace_id, operation_id).await?;
|
|
||||||
let (period, created_after, created_before, bucket) =
|
|
||||||
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.get_usage_for_operation(
|
|
||||||
UsageQuery {
|
|
||||||
workspace_id,
|
|
||||||
period,
|
|
||||||
source: query.source,
|
|
||||||
created_after: &created_after,
|
|
||||||
created_before: &created_before,
|
|
||||||
bucket,
|
|
||||||
},
|
|
||||||
operation_id,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!(
|
|
||||||
"usage for operation {} was not found",
|
|
||||||
operation_id.as_str()
|
|
||||||
),
|
|
||||||
json!({ "operation_id": operation_id.as_str() }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_agent_usage(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
query: UsageRequestQuery,
|
|
||||||
) -> Result<UsageRollupRecord, ApiError> {
|
|
||||||
self.get_agent(workspace_id, agent_id).await?;
|
|
||||||
let (period, created_after, created_before, bucket) =
|
|
||||||
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
|
||||||
|
|
||||||
self.registry
|
|
||||||
.get_usage_for_agent(
|
|
||||||
UsageQuery {
|
|
||||||
workspace_id,
|
|
||||||
period,
|
|
||||||
source: query.source,
|
|
||||||
created_after: &created_after,
|
|
||||||
created_before: &created_before,
|
|
||||||
bucket,
|
|
||||||
},
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::not_found_with_context(
|
|
||||||
format!("usage for agent {} was not found", agent_id.as_str()),
|
|
||||||
json!({ "agent_id": agent_id.as_str() }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_usage_window(
|
|
||||||
period: UsagePeriod,
|
|
||||||
query: &LogsQuery,
|
|
||||||
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
|
|
||||||
resolve_explicit_or_period_window(
|
|
||||||
period,
|
|
||||||
query.created_after.as_deref(),
|
|
||||||
query.created_before.as_deref(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_usage_request_window(
|
|
||||||
period: UsagePeriod,
|
|
||||||
query: &UsageRequestQuery,
|
|
||||||
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
|
|
||||||
resolve_explicit_or_period_window(
|
|
||||||
period,
|
|
||||||
query.created_after.as_deref(),
|
|
||||||
query.created_before.as_deref(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_explicit_or_period_window(
|
|
||||||
period: UsagePeriod,
|
|
||||||
created_after: Option<&str>,
|
|
||||||
created_before: Option<&str>,
|
|
||||||
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
|
|
||||||
match (created_after, created_before) {
|
|
||||||
(None, None) => usage_window(period),
|
|
||||||
(Some(_), None) | (None, Some(_)) => Err(invalid_usage_window(
|
|
||||||
"usage window requires both created_after and created_before",
|
|
||||||
)),
|
|
||||||
(Some(after), Some(before)) => {
|
|
||||||
let after = OffsetDateTime::parse(after, &Rfc3339)
|
|
||||||
.map_err(|_| invalid_usage_window("created_after must be RFC3339 UTC"))?;
|
|
||||||
let before = OffsetDateTime::parse(before, &Rfc3339)
|
|
||||||
.map_err(|_| invalid_usage_window("created_before must be RFC3339 UTC"))?;
|
|
||||||
if after >= before {
|
|
||||||
return Err(invalid_usage_window(
|
|
||||||
"created_after must be before created_before",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let duration = before - after;
|
|
||||||
if duration > time::Duration::days(MAX_EXPLICIT_USAGE_WINDOW_DAYS) {
|
|
||||||
return Err(invalid_usage_window("usage window exceeds maximum range"));
|
|
||||||
}
|
|
||||||
let bucket = if duration <= time::Duration::days(1) {
|
|
||||||
crank_registry::UsageBucket::Hour
|
|
||||||
} else if duration <= time::Duration::days(14) {
|
|
||||||
crank_registry::UsageBucket::Day
|
|
||||||
} else if duration <= time::Duration::days(45) {
|
|
||||||
crank_registry::UsageBucket::Week
|
|
||||||
} else {
|
|
||||||
crank_registry::UsageBucket::Month
|
|
||||||
};
|
|
||||||
Ok((
|
|
||||||
period,
|
|
||||||
after
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
before
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
bucket,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalid_usage_window(message: &'static str) -> ApiError {
|
|
||||||
ApiError::unprocessable_with_context(message, json!({ "error_code": "usage_window_invalid" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_logs_cursor(
|
|
||||||
cursor: Option<&str>,
|
|
||||||
) -> Result<(Option<String>, Option<InvocationLogId>), ApiError> {
|
|
||||||
let Some(cursor) = cursor else {
|
|
||||||
return Ok((None, None));
|
|
||||||
};
|
|
||||||
if cursor.len() > 512 {
|
|
||||||
return Err(invalid_logs_cursor());
|
|
||||||
}
|
|
||||||
let bytes = URL_SAFE_NO_PAD
|
|
||||||
.decode(cursor)
|
|
||||||
.map_err(|_| invalid_logs_cursor())?;
|
|
||||||
let decoded: LogsCursor = serde_json::from_slice(&bytes).map_err(|_| invalid_logs_cursor())?;
|
|
||||||
OffsetDateTime::parse(&decoded.created_at, &Rfc3339).map_err(|_| invalid_logs_cursor())?;
|
|
||||||
if decoded.id.is_empty()
|
|
||||||
|| decoded.id.len() > 128
|
|
||||||
|| decoded.id.contains(';')
|
|
||||||
|| decoded.id.contains(',')
|
|
||||||
{
|
|
||||||
return Err(invalid_logs_cursor());
|
|
||||||
}
|
|
||||||
Ok((
|
|
||||||
Some(decoded.created_at),
|
|
||||||
Some(InvocationLogId::new(decoded.id)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_logs_cursor(record: &InvocationLogRecord) -> Result<String, ApiError> {
|
|
||||||
let cursor = LogsCursor {
|
|
||||||
created_at: record
|
|
||||||
.log
|
|
||||||
.created_at
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
||||||
id: record.log.id.as_str().to_owned(),
|
|
||||||
};
|
|
||||||
let encoded =
|
|
||||||
serde_json::to_vec(&cursor).map_err(|error| ApiError::internal(error.to_string()))?;
|
|
||||||
Ok(URL_SAFE_NO_PAD.encode(encoded))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalid_logs_cursor() -> ApiError {
|
|
||||||
ApiError::unprocessable_with_context(
|
|
||||||
"logs cursor is invalid",
|
|
||||||
json!({ "error_code": "logs_cursor_invalid" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_csv_text<T: serde::Serialize>(value: T) -> Result<String, ApiError> {
|
|
||||||
serde_json::to_value(value)
|
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?
|
|
||||||
.as_str()
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.ok_or_else(|| ApiError::internal("failed to serialize CSV enum value"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn csv_cell(mut value: String) -> String {
|
|
||||||
if value
|
|
||||||
.chars()
|
|
||||||
.next()
|
|
||||||
.is_some_and(|ch| matches!(ch, '=' | '+' | '-' | '@' | '\t' | '\r' | '\n'))
|
|
||||||
{
|
|
||||||
value.insert(0, '\'');
|
|
||||||
}
|
|
||||||
format!("\"{}\"", value.replace('"', "\"\""))
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
use crank_core::{
|
|
||||||
OnboardingProjection, OnboardingStepId, ProductEventId, ProductEventKind, WorkspaceId,
|
|
||||||
};
|
|
||||||
use crank_registry::{
|
|
||||||
OnboardingPresentationMilestone, RecordOnboardingMilestoneRequest, RegistryError,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::ApiError,
|
|
||||||
service::{
|
|
||||||
AdminService, OnboardingEventPayload, OnboardingEventResponse, OnboardingFirstCallEvidence,
|
|
||||||
OnboardingResponse, OnboardingStepView, ResetOnboardingSelectionResponse, format_timestamp,
|
|
||||||
new_prefixed_id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
impl AdminService {
|
|
||||||
pub async fn get_onboarding(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
) -> Result<OnboardingResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let projection = self
|
|
||||||
.registry
|
|
||||||
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
|
|
||||||
.await?;
|
|
||||||
self.map_onboarding_response(projection).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn record_onboarding_event(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
payload: OnboardingEventPayload,
|
|
||||||
) -> Result<OnboardingEventResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
if payload.completed_steps.is_some() || payload.event == "completed" {
|
|
||||||
return Err(onboarding_event_not_allowed());
|
|
||||||
}
|
|
||||||
if payload.idempotency_key.is_empty() || payload.idempotency_key.len() > 256 {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"idempotency_key must contain 1..256 bytes",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if payload.idempotency_key.starts_with("onboarding:") {
|
|
||||||
return Err(onboarding_event_not_allowed());
|
|
||||||
}
|
|
||||||
let (milestone, event_kind) = match payload.event.as_str() {
|
|
||||||
"started" => (
|
|
||||||
OnboardingPresentationMilestone::Started,
|
|
||||||
ProductEventKind::OnboardingStarted,
|
|
||||||
),
|
|
||||||
"resumed" => (
|
|
||||||
OnboardingPresentationMilestone::Resumed,
|
|
||||||
ProductEventKind::OnboardingResumed,
|
|
||||||
),
|
|
||||||
"dismissed" => (
|
|
||||||
OnboardingPresentationMilestone::Dismissed,
|
|
||||||
ProductEventKind::OnboardingDismissed,
|
|
||||||
),
|
|
||||||
"abandoned" => (
|
|
||||||
OnboardingPresentationMilestone::Abandoned,
|
|
||||||
ProductEventKind::OnboardingAbandoned,
|
|
||||||
),
|
|
||||||
_ => return Err(onboarding_event_not_allowed()),
|
|
||||||
};
|
|
||||||
// Presentation events must never exist outside the server-owned cohort,
|
|
||||||
// including a direct API call before the first UI snapshot GET.
|
|
||||||
let current = self
|
|
||||||
.registry
|
|
||||||
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
|
|
||||||
.await?;
|
|
||||||
let outcome = self
|
|
||||||
.registry
|
|
||||||
.record_onboarding_milestone(RecordOnboardingMilestoneRequest {
|
|
||||||
workspace_id,
|
|
||||||
event_id: &ProductEventId::new(new_prefixed_id("pe")),
|
|
||||||
milestone,
|
|
||||||
idempotency_key: &payload.idempotency_key,
|
|
||||||
expected_revision: payload.expected_revision,
|
|
||||||
occurred_at: OffsetDateTime::now_utc(),
|
|
||||||
eligible_since: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let outcome = match outcome {
|
|
||||||
Ok(outcome) => outcome,
|
|
||||||
Err(RegistryError::OnboardingStaleRevision) => {
|
|
||||||
let replay = self
|
|
||||||
.registry
|
|
||||||
.get_product_event_by_idempotency_key(workspace_id, &payload.idempotency_key)
|
|
||||||
.await?;
|
|
||||||
if replay
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|record| record.event.kind == event_kind)
|
|
||||||
{
|
|
||||||
return Ok(OnboardingEventResponse {
|
|
||||||
accepted: false,
|
|
||||||
onboarding: self.map_onboarding_response(current).await?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if replay.is_some() {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"idempotency key was already used for another onboarding event",
|
|
||||||
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"onboarding state changed; reload before retrying",
|
|
||||||
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(RegistryError::InvalidExecutionRecord {
|
|
||||||
field: "product_event.idempotency_conflict",
|
|
||||||
}) => {
|
|
||||||
return Err(ApiError::conflict_with_context(
|
|
||||||
"idempotency key was already used for another onboarding event",
|
|
||||||
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(other) => return Err(other.into()),
|
|
||||||
};
|
|
||||||
Ok(OnboardingEventResponse {
|
|
||||||
accepted: outcome.accepted,
|
|
||||||
onboarding: self.map_onboarding_response(outcome.projection).await?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reset_onboarding_selection(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
expected_revision: i64,
|
|
||||||
) -> Result<ResetOnboardingSelectionResponse, ApiError> {
|
|
||||||
self.ensure_workspace_exists(workspace_id).await?;
|
|
||||||
let projection = self
|
|
||||||
.registry
|
|
||||||
.reset_onboarding_selection(workspace_id, expected_revision, OffsetDateTime::now_utc())
|
|
||||||
.await
|
|
||||||
.map_err(|error| match error {
|
|
||||||
RegistryError::OnboardingStaleRevision => ApiError::conflict_with_context(
|
|
||||||
"onboarding state changed; reload before resetting the selection",
|
|
||||||
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
|
|
||||||
),
|
|
||||||
other => ApiError::from(other),
|
|
||||||
})?;
|
|
||||||
Ok(ResetOnboardingSelectionResponse {
|
|
||||||
selection_reset: true,
|
|
||||||
onboarding: self.map_onboarding_response(projection).await?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn map_onboarding_response(
|
|
||||||
&self,
|
|
||||||
projection: OnboardingProjection,
|
|
||||||
) -> Result<OnboardingResponse, ApiError> {
|
|
||||||
let endpoint = if let Some(agent_id) = projection.agent_id.as_ref() {
|
|
||||||
let workspace = self.get_workspace(&projection.workspace_id).await?;
|
|
||||||
let agent = self
|
|
||||||
.registry
|
|
||||||
.get_agent_summary(&projection.workspace_id, agent_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::internal("onboarding agent disappeared"))?;
|
|
||||||
Some(self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let first_call = match (
|
|
||||||
projection.first_call_log_id.as_ref(),
|
|
||||||
projection.agent_id.as_ref(),
|
|
||||||
projection.platform_api_key_id.as_ref(),
|
|
||||||
projection.operation_id.as_ref(),
|
|
||||||
projection.operation_version,
|
|
||||||
projection.first_call_tool_name.as_ref(),
|
|
||||||
projection.first_call_at,
|
|
||||||
) {
|
|
||||||
(
|
|
||||||
Some(log_id),
|
|
||||||
Some(agent_id),
|
|
||||||
Some(key_id),
|
|
||||||
Some(operation_id),
|
|
||||||
Some(operation_version),
|
|
||||||
Some(tool_name),
|
|
||||||
Some(occurred_at),
|
|
||||||
) => Some(OnboardingFirstCallEvidence {
|
|
||||||
log_id: log_id.as_str().to_owned(),
|
|
||||||
agent_id: agent_id.as_str().to_owned(),
|
|
||||||
key_id: key_id.as_str().to_owned(),
|
|
||||||
operation_id: operation_id.as_str().to_owned(),
|
|
||||||
operation_version,
|
|
||||||
tool_name: tool_name.clone(),
|
|
||||||
occurred_at: format_timestamp(occurred_at),
|
|
||||||
request_id: projection.first_call_request_id.clone(),
|
|
||||||
trace_id: projection.first_call_trace_id.clone(),
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let first_incomplete = projection.steps.iter().position(|step| !step.completed);
|
|
||||||
Ok(OnboardingResponse {
|
|
||||||
schema_version: 1,
|
|
||||||
workspace_id: projection.workspace_id.as_str().to_owned(),
|
|
||||||
revision: projection.revision,
|
|
||||||
status: if projection.completed {
|
|
||||||
"complete".to_owned()
|
|
||||||
} else {
|
|
||||||
"in_progress".to_owned()
|
|
||||||
},
|
|
||||||
completed: projection.completed,
|
|
||||||
eligible_since: projection.eligible_since.map(format_timestamp),
|
|
||||||
steps: projection
|
|
||||||
.steps
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, step)| {
|
|
||||||
let status = if step.completed {
|
|
||||||
"complete"
|
|
||||||
} else if projection.was_completed {
|
|
||||||
"regressed"
|
|
||||||
} else if Some(index) == first_incomplete {
|
|
||||||
"current"
|
|
||||||
} else {
|
|
||||||
"pending"
|
|
||||||
};
|
|
||||||
let reason_code = match status {
|
|
||||||
"complete" => "authoritative_evidence_present",
|
|
||||||
"regressed" => "authoritative_evidence_regressed",
|
|
||||||
"current" => "authoritative_evidence_missing",
|
|
||||||
_ => "prerequisite_incomplete",
|
|
||||||
};
|
|
||||||
OnboardingStepView {
|
|
||||||
id: step.id,
|
|
||||||
completed: step.completed,
|
|
||||||
status: status.to_owned(),
|
|
||||||
action_code: action_code(step.id).to_owned(),
|
|
||||||
reason_code: reason_code.to_owned(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
operation_id: projection.operation_id.map(|id| id.as_str().to_owned()),
|
|
||||||
operation_version: projection.operation_version,
|
|
||||||
agent_id: projection.agent_id.map(|id| id.as_str().to_owned()),
|
|
||||||
catalog_revision: projection.catalog_revision,
|
|
||||||
platform_api_key_id: projection
|
|
||||||
.platform_api_key_id
|
|
||||||
.map(|id| id.as_str().to_owned()),
|
|
||||||
mcp_endpoint: endpoint,
|
|
||||||
first_call,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn action_code(step: OnboardingStepId) -> &'static str {
|
|
||||||
match step {
|
|
||||||
OnboardingStepId::Operation => "create_operation",
|
|
||||||
OnboardingStepId::Test => "test_operation",
|
|
||||||
OnboardingStepId::PublishOperation => "publish_operation",
|
|
||||||
OnboardingStepId::Agent => "publish_agent",
|
|
||||||
OnboardingStepId::Key => "create_mcp_key",
|
|
||||||
OnboardingStepId::McpConnection => "connect_mcp_client",
|
|
||||||
OnboardingStepId::FirstCall => "call_tool",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn onboarding_event_not_allowed() -> ApiError {
|
|
||||||
ApiError::unprocessable_with_context(
|
|
||||||
"client cannot complete authoritative onboarding steps",
|
|
||||||
json!({"error_code":"onboarding_event_not_allowed"}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
use crank_core::{Protocol, ResponseCachePolicy, Target};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::error::ApiError;
|
|
||||||
|
|
||||||
const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000;
|
|
||||||
|
|
||||||
pub(super) fn validate_protocol_target(
|
|
||||||
protocol: Protocol,
|
|
||||||
target: &Target,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let is_match = matches!((protocol, target), (Protocol::Rest, Target::Rest(_)));
|
|
||||||
|
|
||||||
if is_match {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ApiError::validation("protocol and target kind must match"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate_execution_timeout(
|
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if !(1..=MAX_OPERATION_TIMEOUT_MS).contains(&execution_config.timeout_ms) {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
format!("operation timeout must be between 1 and {MAX_OPERATION_TIMEOUT_MS} ms"),
|
|
||||||
json!({ "field": "execution_config.timeout_ms" }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate_response_cache_policy(
|
|
||||||
target: &Target,
|
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let Some(ResponseCachePolicy { ttl_ms }) = execution_config.response_cache.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if *ttl_ms == 0 {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"response cache ttl must be greater than zero".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.response_cache.ttl_ms",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match target {
|
|
||||||
Target::Rest(rest_target) if rest_target.method == crank_core::HttpMethod::Get => {}
|
|
||||||
_ => {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"response cache is supported only for REST GET operations".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.response_cache",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if execution_config.auth_profile_ref.is_some() {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"response cache is not supported for operations with auth_profile_ref".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.auth_profile_ref",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate_idempotency_policy(
|
|
||||||
target: &Target,
|
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let Some(policy) = execution_config.idempotency.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if policy.mode == crank_core::IdempotencyMode::Disabled {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if policy.ttl_ms == 0 {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"idempotency ttl must be greater than zero".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.idempotency.ttl_ms",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match target {
|
|
||||||
Target::Rest(rest_target) if rest_target.method != crank_core::HttpMethod::Get => {}
|
|
||||||
_ => {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"idempotency is supported only for mutating REST operations".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.idempotency",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if policy.mode == crank_core::IdempotencyMode::Required
|
|
||||||
&& policy.input_field.as_deref().is_none_or(str::is_empty)
|
|
||||||
&& policy.header_name.as_deref().is_none_or(str::is_empty)
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"required idempotency needs input_field or header_name".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.idempotency",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate_approval_policy(
|
|
||||||
execution_config: &crank_core::ExecutionConfig,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let Some(policy) = execution_config.approval_policy.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if !policy.required {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if policy.ttl_seconds == 0 || policy.ttl_seconds > 300 {
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"approval ttl must be between 1 and 300 seconds".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.approval_policy.ttl_seconds",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(message) = policy.elicitation_message.as_ref()
|
|
||||||
&& message.chars().count() > 240
|
|
||||||
{
|
|
||||||
return Err(ApiError::validation_with_context(
|
|
||||||
"approval elicitation message must be at most 240 characters".to_owned(),
|
|
||||||
json!({
|
|
||||||
"field": "execution_config.approval_policy.elicitation_message",
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_core::{
|
|
||||||
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, OperationApprovalMode,
|
|
||||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
|
||||||
ResponseCachePolicy, RestTarget, Target,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
|
|
||||||
validate_response_cache_policy,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn cacheable_execution_config() -> ExecutionConfig {
|
|
||||||
ExecutionConfig {
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
approval_policy: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn idempotent_execution_config(mode: IdempotencyMode) -> ExecutionConfig {
|
|
||||||
ExecutionConfig {
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: Some(IdempotencyPolicy {
|
|
||||||
mode,
|
|
||||||
ttl_ms: 5_000,
|
|
||||||
input_field: Some("request_id".to_owned()),
|
|
||||||
header_name: Some("Idempotency-Key".to_owned()),
|
|
||||||
}),
|
|
||||||
safety: None,
|
|
||||||
approval_policy: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_response_cache_for_rest_get() {
|
|
||||||
let target = Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Get,
|
|
||||||
path_template: "/catalog".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = validate_response_cache_policy(&target, &cacheable_execution_config());
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_zero_and_excessive_execution_timeouts() {
|
|
||||||
let mut config = cacheable_execution_config();
|
|
||||||
config.timeout_ms = 0;
|
|
||||||
assert!(validate_execution_timeout(&config).is_err());
|
|
||||||
|
|
||||||
config.timeout_ms = 300_001;
|
|
||||||
assert!(validate_execution_timeout(&config).is_err());
|
|
||||||
|
|
||||||
config.timeout_ms = 300_000;
|
|
||||||
assert!(validate_execution_timeout(&config).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_response_cache_for_non_get_rest_operation() {
|
|
||||||
let target = Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Post,
|
|
||||||
path_template: "/catalog".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let error =
|
|
||||||
validate_response_cache_policy(&target, &cacheable_execution_config()).unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"response cache is supported only for REST GET operations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_idempotency_for_mutating_rest_operation() {
|
|
||||||
let target = Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Post,
|
|
||||||
path_template: "/orders".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = validate_idempotency_policy(
|
|
||||||
&target,
|
|
||||||
&idempotent_execution_config(IdempotencyMode::Required),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_idempotency_for_rest_get() {
|
|
||||||
let target = Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Get,
|
|
||||||
path_template: "/orders".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let error = validate_idempotency_policy(
|
|
||||||
&target,
|
|
||||||
&idempotent_execution_config(IdempotencyMode::Optional),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"idempotency is supported only for mutating REST operations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_required_idempotency_without_key_source() {
|
|
||||||
let target = Target::Rest(RestTarget {
|
|
||||||
base_url: "http://example.invalid".to_owned(),
|
|
||||||
method: HttpMethod::Post,
|
|
||||||
path_template: "/orders".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
let mut config = idempotent_execution_config(IdempotencyMode::Required);
|
|
||||||
let policy = config.idempotency.as_mut().unwrap();
|
|
||||||
policy.input_field = None;
|
|
||||||
policy.header_name = None;
|
|
||||||
|
|
||||||
let error = validate_idempotency_policy(&target, &config).unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"required idempotency needs input_field or header_name"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_valid_approval_policy() {
|
|
||||||
let mut config = cacheable_execution_config();
|
|
||||||
config.approval_policy = Some(OperationApprovalPolicy {
|
|
||||||
required: true,
|
|
||||||
mode: OperationApprovalMode::Custom,
|
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
|
||||||
ttl_seconds: 300,
|
|
||||||
show_payload_preview: true,
|
|
||||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
|
||||||
elicitation_message: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
validate_approval_policy(&config).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_approval_policy() {
|
|
||||||
let mut config = cacheable_execution_config();
|
|
||||||
config.approval_policy = Some(OperationApprovalPolicy {
|
|
||||||
required: true,
|
|
||||||
mode: OperationApprovalMode::Custom,
|
|
||||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
|
||||||
ttl_seconds: 0,
|
|
||||||
show_payload_preview: true,
|
|
||||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
|
||||||
elicitation_message: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let error = validate_approval_policy(&config).unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"approval ttl must be between 1 and 300 seconds"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user