Compare commits
28 Commits
master
..
466659c506
| Author | SHA1 | Date | |
|---|---|---|---|
| 466659c506 | |||
| 8375c7bbf0 | |||
| 2ccaba897d | |||
| 619394f509 | |||
| 501e0931c5 | |||
| c3188637b3 | |||
| 32cde544da | |||
| 10641faf43 | |||
| c2556ec236 | |||
| 55209a9bbc | |||
| 6c2a3712d8 | |||
| bc03c33387 | |||
| 2c94af6791 | |||
| d2849ea3fe | |||
| 8784964fb2 | |||
| 889b1bdb57 | |||
| 38ba898b72 | |||
| 2eb185b14a | |||
| 497e1b740f | |||
| 182bde8ac0 | |||
| 767428436d | |||
| b7face0e94 | |||
| 996a5461de | |||
| 5fa5f6efe4 | |||
| 62d98a8846 | |||
| 8e709acea9 | |||
| 17c8e3a8f0 | |||
| f6fc2e5c9b |
+42
-38
@@ -1,46 +1,41 @@
|
||||
POSTGRES_DB=crank
|
||||
POSTGRES_USER=crank
|
||||
POSTGRES_PASSWORD=change-me
|
||||
# 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_USER=crank
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_MAX_CONNECTIONS=20
|
||||
POSTGRES_MIN_CONNECTIONS=2
|
||||
POSTGRES_ACQUIRE_TIMEOUT_MS=5000
|
||||
POSTGRES_IDLE_TIMEOUT_MS=600000
|
||||
POSTGRES_MAX_LIFETIME_MS=1800000
|
||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
||||
CRANK_UI_IMAGE=crank/ui:dev
|
||||
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_BURST=60
|
||||
CRANK_MCP_BIND=0.0.0.0:3002
|
||||
CRANK_MCP_REFRESH_MS=5000
|
||||
CRANK_MCP_RATE_LIMIT_RPS=60
|
||||
CRANK_MCP_RATE_LIMIT_BURST=120
|
||||
CRANK_MASTER_KEY=
|
||||
CRANK_BASE_URL=http://localhost:3000
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||
# Публичные узлы разрешены по умолчанию. Для внутренних API перечислите
|
||||
# допустимые имена или IP через запятую.
|
||||
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=info
|
||||
# Пустое значение отключает канал критических ошибок.
|
||||
CRANK_LOG_LEVEL=
|
||||
CRANK_SENTRY_DSN=
|
||||
# Prometheus endpoints use separate listeners and stay on loopback by default.
|
||||
CRANK_METRICS_ENABLED=true
|
||||
CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464
|
||||
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
|
||||
# Required when either metrics listener uses a non-loopback address.
|
||||
CRANK_METRICS_BEARER_TOKEN=
|
||||
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
|
||||
# Пустой endpoint полностью отключает экспорт трасс.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
@@ -53,15 +48,24 @@ OTEL_BSP_MAX_QUEUE_SIZE=2048
|
||||
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_BSP_SCHEDULE_DELAY=5000
|
||||
OTEL_BSP_EXPORT_TIMEOUT=30000
|
||||
CRANK_MASTER_KEY=change-me-master-key
|
||||
CRANK_SESSION_SECRET=change-me-session-secret
|
||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||
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_ADMIN_RATE_LIMIT_RPS=30
|
||||
CRANK_ADMIN_RATE_LIMIT_BURST=60
|
||||
CRANK_INVOCATION_LOG_RETENTION_DAYS=30
|
||||
CRANK_SESSION_SECRET=
|
||||
CRANK_PASSWORD_PEPPER=
|
||||
CRANK_SESSION_TTL_HOURS=24
|
||||
# Trust X-Real-IP / X-Forwarded-For for client rate limiting. Enable only when
|
||||
# admin-api runs behind the bundled nginx (or another trusted reverse proxy).
|
||||
CRANK_TRUST_FORWARDED_HEADERS=true
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||
CRANK_TRUSTED_PROXY_IPS=
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||
CRANK_DEMO_SEED=true
|
||||
CRANK_BASE_URL=https://crank.example.com
|
||||
CRANK_DEMO_SEED=false
|
||||
CRANK_MCP_BIND=0.0.0.0:3002
|
||||
CRANK_MCP_METRICS_BIND=127.0.0.1:9465
|
||||
CRANK_MCP_REFRESH_MS=5000
|
||||
CRANK_MCP_RATE_LIMIT_RPS=60
|
||||
CRANK_MCP_RATE_LIMIT_BURST=120
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||
# END GENERATED CRANK RUNTIME CONFIG
|
||||
|
||||
+64
-4
@@ -61,6 +61,39 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -80,7 +113,7 @@ jobs:
|
||||
run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS"
|
||||
run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS" -- --test-threads=1
|
||||
|
||||
ui:
|
||||
name: UI Checks
|
||||
@@ -181,7 +214,31 @@ jobs:
|
||||
|
||||
- name: Run Playwright e2e
|
||||
working-directory: apps/ui
|
||||
run: npx playwright test
|
||||
run: |
|
||||
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 upload invalidates active draft creation 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
|
||||
if: failure()
|
||||
@@ -199,8 +256,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Validate Community deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
- name: Validate Community 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: Build Community images
|
||||
run: |
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
python3 --version
|
||||
rustc --version
|
||||
cargo --version
|
||||
node --version
|
||||
@@ -52,11 +53,51 @@ jobs:
|
||||
- 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
|
||||
cargo test --workspace --all-targets -- --test-threads=1
|
||||
cargo deny --locked check advisories bans licenses sources
|
||||
|
||||
- name: Build release binaries
|
||||
@@ -80,23 +121,55 @@ jobs:
|
||||
|
||||
- name: Run release end-to-end tests
|
||||
working-directory: apps/ui
|
||||
run: npm run e2e
|
||||
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: Validate deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
- 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 upload invalidates active draft creation 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
|
||||
run: |
|
||||
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/crank-migrate dist/release/crank-migrate
|
||||
cp target/release/mcp-server dist/release/mcp-server
|
||||
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api
|
||||
cp .tmp/openapi-ui-evidence.json "$evidence_artifact"
|
||||
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 apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz .
|
||||
sha256sum \
|
||||
dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \
|
||||
dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \
|
||||
dist/crank-community-ui-${IMAGE_TAG}.tar.gz \
|
||||
"$evidence_artifact" \
|
||||
> dist/crank-community-${IMAGE_TAG}-checksums.txt
|
||||
|
||||
- name: Generate SBOM
|
||||
@@ -130,6 +203,32 @@ jobs:
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-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_BOOTSTRAP_ADMIN_PASSWORD=release-smoke-password
|
||||
CRANK_BASE_URL=http://127.0.0.1
|
||||
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 }}' \
|
||||
|
||||
@@ -23,3 +23,10 @@ __*.md
|
||||
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
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+98
-14
@@ -6,72 +6,156 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "category"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "security_level"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_versions",
|
||||
"name": "target_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "current_draft_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "latest_published_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "published_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
{
|
||||
"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.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"
|
||||
},
|
||||
{
|
||||
"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": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb"
|
||||
}
|
||||
+42
-6
@@ -6,32 +6,68 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "agent_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "instructions_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "instructions_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "tool_selection_policy_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "tool_selection_policy_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_versions",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+56
-8
@@ -6,42 +6,90 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "operation_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "sample_kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "sample_kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "storage_ref",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "storage_ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "content_type",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "content_type"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "file_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "file_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_samples",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+77
-11
@@ -6,57 +6,123 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "description"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "current_draft_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "latest_published_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "published_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"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.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"
|
||||
},
|
||||
{
|
||||
"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": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b"
|
||||
}
|
||||
+35
-5
@@ -6,27 +6,57 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "allowed!",
|
||||
"type_info": "Bool"
|
||||
"type_info": "Bool",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "present!",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+49
-7
@@ -6,37 +6,79 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "settings_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "settings_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+56
-8
@@ -6,42 +6,90 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "operation_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "descriptor_kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "descriptor_kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "storage_ref",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "storage_ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "source_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "source_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "package_index_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "package_index_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "descriptors",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "present!",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
{
|
||||
"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.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"
|
||||
},
|
||||
{
|
||||
"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": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce"
|
||||
}
|
||||
+77
-11
@@ -6,57 +6,123 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "description"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "current_draft_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "latest_published_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "published_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+49
-7
@@ -6,37 +6,79 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "config_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "config_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+56
-8
@@ -6,42 +6,90 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "memberships",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "memberships",
|
||||
"name": "user_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "role",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "memberships",
|
||||
"name": "role"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "memberships",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-209
@@ -1,209 +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 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.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"
|
||||
},
|
||||
{
|
||||
"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": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec"
|
||||
}
|
||||
+63
-9
@@ -6,47 +6,101 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "current_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "current_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_used_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "last_used_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+49
-7
@@ -6,37 +6,79 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "settings_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "settings_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "workspaces",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+63
-9
@@ -6,47 +6,101 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "current_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "current_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_used_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secrets",
|
||||
"name": "last_used_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+56
-8
@@ -6,42 +6,90 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "role",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "role"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "token_hash",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "token_hash"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "expires_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "invitation_tokens",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+98
-14
@@ -6,72 +6,156 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "category"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "security_level"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operation_versions",
|
||||
"name": "target_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "current_draft_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "latest_published_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "operations",
|
||||
"name": "published_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+49
-7
@@ -6,37 +6,79 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "user_sessions",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "user_sessions",
|
||||
"name": "user_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "current_workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "user_sessions",
|
||||
"name": "current_workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "users",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+49
-7
@@ -6,37 +6,79 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "workspace_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "kind"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "config_json",
|
||||
"type_info": "Jsonb"
|
||||
"type_info": "Jsonb",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "config_json"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "auth_profiles",
|
||||
"name": "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+42
-6
@@ -6,32 +6,68 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "secret_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "secret_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int4",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "ciphertext",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "ciphertext"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "key_version",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "key_version"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
"type_info": "Timestamptz",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "created_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "secret_versions",
|
||||
"name": "created_by"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
+28
-4
@@ -6,22 +6,46 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agent_operation_bindings",
|
||||
"name": "operation_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_slug",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "slug"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "agents",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
{
|
||||
"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.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"
|
||||
},
|
||||
{
|
||||
"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": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"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,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4"
|
||||
}
|
||||
@@ -30,10 +30,25 @@ 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 не поддерживаются независимыми ручными таблицами.
|
||||
|
||||
## Границы проекта
|
||||
|
||||
@@ -47,3 +62,5 @@ npx playwright test
|
||||
- необязательный Valkey или Redis для служебного кэша.
|
||||
|
||||
Функции за пределами перечисленного набора не должны попадать в этот репозиторий.
|
||||
Целевые Resources, Prompts, Tasks и Load Runs допускаются только через
|
||||
проверяемое изменение Inventory, guardrails, tests и документации.
|
||||
|
||||
Generated
+94
-28
@@ -26,10 +26,14 @@ dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"crank-artifacts",
|
||||
"crank-community-auth",
|
||||
"crank-community-mcp",
|
||||
"crank-config",
|
||||
"crank-core",
|
||||
"crank-import",
|
||||
"crank-mapping",
|
||||
"crank-metrics",
|
||||
"crank-observability",
|
||||
"crank-registry",
|
||||
"crank-runtime",
|
||||
@@ -55,6 +59,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -256,6 +261,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -516,9 +522,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -576,15 +582,6 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -670,6 +667,17 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-artifacts"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand 0.10.2",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-community-auth"
|
||||
version = "0.3.1"
|
||||
@@ -723,6 +731,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-config"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-core"
|
||||
version = "0.3.1"
|
||||
@@ -734,6 +752,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -795,17 +814,18 @@ dependencies = [
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-registry"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"crank-artifacts",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-schema",
|
||||
"crank-test-support",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -874,7 +894,10 @@ dependencies = [
|
||||
name = "crank-trace"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"crank-core",
|
||||
"opentelemetry",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -1088,6 +1111,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.1.2"
|
||||
@@ -1107,7 +1139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1122,11 +1154,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1384,9 +1415,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1633,7 +1664,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2019,6 +2050,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"crank-community-mcp",
|
||||
"crank-config",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-observability",
|
||||
@@ -2116,6 +2148,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2136,6 +2178,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nibble_vec"
|
||||
version = "0.1.0"
|
||||
@@ -2151,7 +2210,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2568,7 +2627,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2607,9 +2666,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2836,6 +2895,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2947,7 +3007,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3006,7 +3066,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3451,9 +3511,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
@@ -4104,6 +4164,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -4404,7 +4470,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@ members = [
|
||||
"apps/mcp-server",
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-community-mcp",
|
||||
"crates/crank-config",
|
||||
"crates/crank-core",
|
||||
"crates/crank-import",
|
||||
"crates/crank-schema",
|
||||
@@ -15,6 +16,7 @@ members = [
|
||||
"crates/crank-test-support",
|
||||
"crates/crank-trace",
|
||||
"crates/crank-adapter-rest",
|
||||
"crates/crank-artifacts",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
@@ -28,7 +30,7 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||
base64 = "0.22"
|
||||
hkdf = "0.12"
|
||||
@@ -41,7 +43,7 @@ opentelemetry_sdk = { version = "0.32.1", default-features = false, features = [
|
||||
percent-encoding = "2"
|
||||
prost = "0.14"
|
||||
rand = "0.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -165,6 +165,20 @@ docker compose up -d --build
|
||||
- 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
|
||||
@@ -268,7 +282,10 @@ npx playwright test
|
||||
- [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)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
|
||||
@@ -10,15 +10,23 @@ version.workspace = true
|
||||
name = "admin-api"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "crank-migrate"
|
||||
path = "src/bin/crank-migrate.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
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-import = { path = "../../crates/crank-import" }
|
||||
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-runtime = { path = "../../crates/crank-runtime" }
|
||||
@@ -32,13 +40,14 @@ sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
tokio = { workspace = true, features = ["fs", "time"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
|
||||
crank-test-support = { path = "../../crates/crank-test-support" }
|
||||
metrics.workspace = true
|
||||
metrics-util = "0.20.4"
|
||||
|
||||
+19
-44
@@ -1,41 +1,3 @@
|
||||
FROM rust:1.96.1-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.96.1-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
@@ -45,24 +7,37 @@ COPY .sqlx ./.sqlx
|
||||
COPY apps ./apps
|
||||
COPY crates ./crates
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/app/target \
|
||||
RUN --mount=type=cache,id=crank-admin-cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=crank-admin-cargo-git,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,id=crank-admin-target,target=/app/target \
|
||||
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
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
||||
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
|
||||
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get -o Acquire::Retries=3 update \
|
||||
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/crank-admin-entrypoint
|
||||
|
||||
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||
ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/crank-admin-entrypoint"]
|
||||
CMD ["admin-api"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,10 +1,16 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use crank_artifacts::MAX_ARTIFACT_BYTES;
|
||||
|
||||
use crate::{
|
||||
auth::{require_session, require_workspace_session},
|
||||
auth::{
|
||||
enforce_browser_security, require_csrf_for_browser_mutations, require_session,
|
||||
require_workspace_session,
|
||||
},
|
||||
rate_limit::apply_api_rate_limit,
|
||||
request_context::apply_request_context,
|
||||
routes::{
|
||||
@@ -15,14 +21,18 @@ use crate::{
|
||||
list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
|
||||
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
|
||||
},
|
||||
auth::{change_password, get_profile, get_session, login, logout, update_profile},
|
||||
auth::{
|
||||
bootstrap_status, change_password, complete_bootstrap, get_profile, get_session, login,
|
||||
logout, refresh_session_csrf, update_profile,
|
||||
},
|
||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||
capabilities::get_capabilities,
|
||||
imports::{create_openapi_import, preview_openapi_import},
|
||||
observability::{
|
||||
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals,
|
||||
list_logs,
|
||||
approve_approval, deny_approval, export_logs_csv, export_usage_csv, get_agent_usage,
|
||||
get_approval, get_log, get_operation_usage, get_usage, list_approvals, list_logs,
|
||||
},
|
||||
onboarding::{get_onboarding, record_onboarding_event, reset_onboarding_selection},
|
||||
operations::{
|
||||
analyze_operation_quality, archive_operation, create_operation, create_version,
|
||||
delete_operation, export_operation, generate_draft, get_operation,
|
||||
@@ -39,7 +49,11 @@ use crate::{
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
let workspace_router = Router::new()
|
||||
.route("/operations", get(list_operations).post(create_operation))
|
||||
.route("/imports/openapi/preview", post(preview_openapi_import))
|
||||
.route(
|
||||
"/imports/openapi/preview",
|
||||
post(preview_openapi_import)
|
||||
.layer(DefaultBodyLimit::max(MAX_ARTIFACT_BYTES + 32 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/imports/openapi/{job_id}/create",
|
||||
post(create_openapi_import),
|
||||
@@ -48,7 +62,10 @@ pub fn build_app(state: AppState) -> Router {
|
||||
"/operations/analyze-quality",
|
||||
post(analyze_operation_quality),
|
||||
)
|
||||
.route("/operations/import", post(import_operation))
|
||||
.route(
|
||||
"/operations/import",
|
||||
post(import_operation).layer(DefaultBodyLimit::max(256 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}",
|
||||
get(get_operation)
|
||||
@@ -126,12 +143,22 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
|
||||
.route("/export", get(export_workspace))
|
||||
.route("/logs", get(list_logs))
|
||||
.route("/logs/export.csv", get(export_logs_csv))
|
||||
.route("/logs/{log_id}", get(get_log))
|
||||
.route("/approvals", get(list_approvals))
|
||||
.route("/approvals/{approval_id}", get(get_approval))
|
||||
.route("/approvals/{approval_id}/approve", post(approve_approval))
|
||||
.route("/approvals/{approval_id}/deny", post(deny_approval))
|
||||
.route("/usage/export.csv", get(export_usage_csv))
|
||||
.route("/usage", get(get_usage))
|
||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage));
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage))
|
||||
.route("/onboarding", get(get_onboarding))
|
||||
.route("/onboarding/events", post(record_onboarding_event))
|
||||
.route(
|
||||
"/onboarding/reset-selection",
|
||||
post(reset_onboarding_selection),
|
||||
);
|
||||
|
||||
let workspace_root_router = Router::new()
|
||||
.route("/capabilities", get(get_capabilities))
|
||||
@@ -157,6 +184,7 @@ pub fn build_app(state: AppState) -> Router {
|
||||
let protected_auth_router = Router::new()
|
||||
.route("/logout", post(logout))
|
||||
.route("/session", get(get_session))
|
||||
.route("/session/csrf", post(refresh_session_csrf))
|
||||
.route("/profile", get(get_profile).patch(update_profile))
|
||||
.route("/password", post(change_password))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
@@ -170,10 +198,20 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.nest(
|
||||
"/api/auth",
|
||||
Router::new()
|
||||
.route("/bootstrap/status", get(bootstrap_status))
|
||||
.route("/bootstrap/complete", post(complete_bootstrap))
|
||||
.route("/login", post(login))
|
||||
.merge(protected_auth_router),
|
||||
)
|
||||
.nest("/api/admin", admin_router)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_csrf_for_browser_mutations,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
enforce_browser_security,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
apply_api_rate_limit,
|
||||
|
||||
+131
-3
@@ -1,22 +1,24 @@
|
||||
use axum::{
|
||||
extract::{OriginalUri, Request, State},
|
||||
http::{HeaderValue, Method, header},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use crank_community_auth::{
|
||||
cleared_session_cookie as build_cleared_session_cookie,
|
||||
cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token,
|
||||
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
||||
session_cookie as build_session_cookie_header,
|
||||
};
|
||||
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||
use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId};
|
||||
use crank_registry::WorkspaceMembershipRecord;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub use crank_community_auth::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
|
||||
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
|
||||
hash_session_secret, verify_password,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
|
||||
.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> {
|
||||
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
||||
}
|
||||
@@ -90,11 +96,81 @@ pub async fn require_workspace_session(
|
||||
if !has_access {
|
||||
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);
|
||||
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(
|
||||
state: AppState,
|
||||
jar: &CookieJar,
|
||||
@@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
#[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(())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
+183
-11
@@ -1,13 +1,15 @@
|
||||
use crank_core::{
|
||||
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
|
||||
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
|
||||
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
|
||||
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
|
||||
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::{
|
||||
PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
};
|
||||
use crank_schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -19,11 +21,23 @@ pub struct LoginPayload {
|
||||
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)]
|
||||
@@ -80,12 +94,19 @@ pub struct PublishPayload {
|
||||
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>,
|
||||
@@ -228,6 +249,7 @@ pub struct AgentSummaryView {
|
||||
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>,
|
||||
@@ -266,6 +288,91 @@ fn default_platform_api_key_kind() -> PlatformApiKeyKind {
|
||||
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)]
|
||||
@@ -285,11 +392,16 @@ pub struct WorkspaceCatalogSnapshotResponse {
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -299,10 +411,19 @@ pub struct ApprovalsQuery {
|
||||
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)]
|
||||
@@ -311,6 +432,13 @@ pub struct UsageOverviewResponse {
|
||||
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)]
|
||||
@@ -362,15 +490,51 @@ pub struct ExportQuery {
|
||||
}
|
||||
|
||||
#[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, Debug, Deserialize)]
|
||||
pub struct OpenApiImportPreviewPayload {
|
||||
pub document: String,
|
||||
#[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)]
|
||||
@@ -396,14 +560,15 @@ pub struct OpenApiImportCreateResponse {
|
||||
pub findings: Vec<crank_import::rest::ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[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, Serialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct OpenApiImportSkippedOperation {
|
||||
pub operation_key: String,
|
||||
pub name: String,
|
||||
@@ -473,6 +638,7 @@ pub struct OperationSummaryView {
|
||||
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>,
|
||||
@@ -490,6 +656,7 @@ pub struct OperationDetailView {
|
||||
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,
|
||||
@@ -503,7 +670,7 @@ pub struct OperationDetailView {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VersionRef {
|
||||
pub version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub status: OperationVersionState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -551,12 +718,17 @@ pub(crate) struct InvocationRecordRequest<'a> {
|
||||
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,
|
||||
|
||||
+401
-94
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::dto::OpenApiUploadLocale;
|
||||
use crate::storage::StorageError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -31,6 +32,11 @@ pub enum ApiError {
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Unprocessable {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
NotFound {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
@@ -41,6 +47,16 @@ pub enum ApiError {
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
PayloadTooLarge {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
PreconditionRequired {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
RateLimited {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
@@ -74,13 +90,111 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
pub fn internal(_message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
message: "internal server error".to_owned(),
|
||||
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 {
|
||||
Self::RateLimited {
|
||||
message: message.into(),
|
||||
@@ -95,6 +209,13 @@ 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 {
|
||||
Self::NotFound {
|
||||
message: message.into(),
|
||||
@@ -109,25 +230,58 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_too_large_with_context(
|
||||
message: impl Into<String>,
|
||||
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(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
|
||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
pub(crate) fn code(&self) -> &str {
|
||||
if let Some(code) = self
|
||||
.context_ref()
|
||||
.and_then(|context| context.get("error_code"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
match self {
|
||||
Self::Unauthorized { .. } => "unauthorized",
|
||||
Self::Forbidden { .. } => "forbidden",
|
||||
Self::Validation { .. } => "validation_error",
|
||||
Self::Unprocessable { .. } => "unprocessable_entity",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::PayloadTooLarge { .. } => "payload_too_large",
|
||||
Self::PreconditionRequired { .. } => "precondition_required",
|
||||
Self::RateLimited { .. } => "rate_limited",
|
||||
Self::Internal { .. } => "internal_error",
|
||||
}
|
||||
@@ -147,8 +301,11 @@ impl IntoResponse for ApiError {
|
||||
Self::Unauthorized { .. }
|
||||
| Self::Forbidden { .. }
|
||||
| Self::Validation { .. }
|
||||
| Self::Unprocessable { .. }
|
||||
| Self::NotFound { .. }
|
||||
| Self::Conflict { .. }
|
||||
| Self::PayloadTooLarge { .. }
|
||||
| Self::PreconditionRequired { .. }
|
||||
| Self::RateLimited { .. } => {
|
||||
warn!(
|
||||
name: "admin.response.rejected",
|
||||
@@ -165,6 +322,13 @@ impl IntoResponse for ApiError {
|
||||
if let Some(context) = self.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!({
|
||||
"error": error
|
||||
@@ -175,17 +339,24 @@ impl IntoResponse for ApiError {
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn context(&self) -> Option<Value> {
|
||||
fn context_ref(&self) -> Option<&Value> {
|
||||
match self {
|
||||
Self::Unauthorized { context, .. }
|
||||
| Self::Forbidden { context, .. }
|
||||
| Self::Validation { context, .. }
|
||||
| Self::Unprocessable { context, .. }
|
||||
| Self::NotFound { context, .. }
|
||||
| Self::Conflict { context, .. }
|
||||
| Self::PayloadTooLarge { context, .. }
|
||||
| Self::PreconditionRequired { context, .. }
|
||||
| Self::RateLimited { context, .. }
|
||||
| Self::Internal { context, .. } => context.clone(),
|
||||
| Self::Internal { context, .. } => context.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn context(&self) -> Option<Value> {
|
||||
self.context_ref().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegistryError> for ApiError {
|
||||
@@ -221,10 +392,41 @@ impl From<RegistryError> for ApiError {
|
||||
format!("platform api key {key_id} was not found"),
|
||||
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(
|
||||
format!("secret {secret_id} was not found"),
|
||||
json!({ "secret_id": secret_id }),
|
||||
),
|
||||
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
|
||||
"secret is not active",
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"error_code": "secret_not_active",
|
||||
"recovery": "rotate_or_replace_secret"
|
||||
}),
|
||||
),
|
||||
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
|
||||
"secret was updated concurrently",
|
||||
json!({
|
||||
"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(
|
||||
format!("invocation log {log_id} was not found"),
|
||||
json!({ "log_id": log_id }),
|
||||
@@ -261,6 +463,57 @@ impl From<RegistryError> for ApiError {
|
||||
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(
|
||||
format!("auth profile {auth_profile_id} was not found"),
|
||||
json!({ "auth_profile_id": auth_profile_id }),
|
||||
@@ -269,6 +522,17 @@ impl From<RegistryError> for ApiError {
|
||||
format!("operation {operation_id} already exists"),
|
||||
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(
|
||||
format!("workspace with slug {slug} already exists"),
|
||||
json!({ "slug": slug }),
|
||||
@@ -279,6 +543,8 @@ impl From<RegistryError> for ApiError {
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"name": name,
|
||||
"error_code": "secret_name_conflict",
|
||||
"recovery": "choose_different_name"
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -290,12 +556,29 @@ impl From<RegistryError> for ApiError {
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"auth_profile_id": auth_profile_id,
|
||||
"error_code": "secret_referenced_by_auth_profile",
|
||||
"recovery": "remove_or_update_auth_profile_reference"
|
||||
}),
|
||||
),
|
||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||
format!("user with email {email} already exists"),
|
||||
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 {
|
||||
operation_id,
|
||||
version,
|
||||
@@ -330,6 +613,39 @@ impl From<RegistryError> for ApiError {
|
||||
"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 {
|
||||
operation_id,
|
||||
field,
|
||||
@@ -340,6 +656,14 @@ impl From<RegistryError> for ApiError {
|
||||
"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(
|
||||
format!("unsupported enum representation for field {field}"),
|
||||
json!({ "field": field }),
|
||||
@@ -363,8 +687,52 @@ impl From<RegistryError> for ApiError {
|
||||
format!("import job {job_id} was already applied with different parameters"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,95 +765,34 @@ impl From<StorageError> for ApiError {
|
||||
}
|
||||
|
||||
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!({
|
||||
"code": runtime_test_failure_code(error),
|
||||
"message": error.to_string()
|
||||
"code": failure.error_code().as_str(),
|
||||
"message": failure.error_code().message(locale),
|
||||
"stage": failure.stage().as_str(),
|
||||
"retryability": failure.retryability().as_str(),
|
||||
"outcome_certainty": failure.outcome_certainty().as_str(),
|
||||
});
|
||||
if let Some(context) = runtime_error_context(error) {
|
||||
payload["context"] = context;
|
||||
if let Some(status) = failure.upstream_status() {
|
||||
payload["context"] = json!({ "upstream_status": status });
|
||||
}
|
||||
if let Some(challenge) = failure.confirmation() {
|
||||
payload["context"] = json!({
|
||||
"confirmation_token": challenge.token(),
|
||||
"expires_in_ms": challenge.expires_in_ms(),
|
||||
});
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_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::ConfirmationRequired {
|
||||
confirmation_token,
|
||||
expires_in_ms,
|
||||
safety_class,
|
||||
..
|
||||
} => Some(json!({
|
||||
"confirmation_token": confirmation_token,
|
||||
"expires_in_ms": expires_in_ms,
|
||||
"safety_class": safety_class,
|
||||
})),
|
||||
RuntimeError::InvalidConfirmationToken { operation_id }
|
||||
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
|
||||
| RuntimeError::IdempotencyInProgress { operation_id }
|
||||
| RuntimeError::IdempotencyConflict { operation_id }
|
||||
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
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::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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod error;
|
||||
pub mod import_guidance;
|
||||
pub mod pool_metrics;
|
||||
pub mod rate_limit;
|
||||
pub mod reconciliation;
|
||||
pub mod request_context;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
+388
-119
@@ -1,18 +1,26 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
use std::{io, net::SocketAddr, process::ExitCode, time::Duration};
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig},
|
||||
pool_metrics::spawn_postgres_pool_metrics,
|
||||
reconciliation::{open_reconciliation_store, spawn_artifact_reconciliation},
|
||||
service::AdminServiceBuilder,
|
||||
state::AppState,
|
||||
};
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_config::{
|
||||
AdminProcessConfig, CacheBackend as ConfigCacheBackend, ConfigSource, DatabaseSettings,
|
||||
DiagnosticCode, ObservabilitySettings, ProcessKind, parse_process,
|
||||
};
|
||||
use crank_core::CacheBackend;
|
||||
use crank_observability::{
|
||||
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
|
||||
capture_critical_error,
|
||||
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
|
||||
};
|
||||
use crank_registry::{
|
||||
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
|
||||
};
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto,
|
||||
@@ -21,17 +29,107 @@ use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const MAX_INVOCATION_LOG_RETENTION_DAYS: i64 = 36_500;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let observability = crank_observability::init(ObservabilityConfig::from_env(
|
||||
"admin-api",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"admin_api=info,tower_http=info",
|
||||
)?)?;
|
||||
async fn main() -> ExitCode {
|
||||
match main_result().await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("{}", safe_startup_diagnostic(error.as_ref()));
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut current = Some(error);
|
||||
while let Some(cause) = current {
|
||||
if let Some(config) = cause.downcast_ref::<crank_config::ConfigError>() {
|
||||
return config.to_json();
|
||||
}
|
||||
if let Some(migration) = cause.downcast_ref::<crank_registry::MigrationError>() {
|
||||
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(&observability, &mut startup_completed).await;
|
||||
let result = run(config, &observability, &mut startup_completed).await;
|
||||
if result.is_err() {
|
||||
capture_critical_error(if startup_completed {
|
||||
CriticalErrorCategory::Internal
|
||||
@@ -43,54 +141,75 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: AdminProcessConfig,
|
||||
observability: &ObservabilityLifecycle,
|
||||
startup_completed: &mut bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let metrics_config =
|
||||
MetricsConfig::from_env("CRANK_ADMIN_METRICS_BIND", "127.0.0.1:9464".parse()?)?;
|
||||
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 metrics_server = if metrics_config.enabled() {
|
||||
let pool_config = postgres_pool_config(&config.database)?;
|
||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||
database_options(&config.database)?,
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
let metrics_server = if metrics_enabled {
|
||||
Some(observability.metrics_surface(metrics_config).bind().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let storage_root = PathBuf::from(
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
);
|
||||
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let pool_config = PostgresPoolConfig::from_env()?;
|
||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||
database_options_from_env()?,
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
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 {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(24),
|
||||
session_secret: config.session_secret.expose_secret().to_owned(),
|
||||
password_pepper: config.password_pepper.expose_secret().to_owned(),
|
||||
session_ttl_hours: config.session_ttl_hours,
|
||||
cookie_secure: base_url.starts_with("https://"),
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
|
||||
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
|
||||
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
|
||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||
email: config.bootstrap_email.clone(),
|
||||
password: config
|
||||
.bootstrap_password
|
||||
.as_ref()
|
||||
.map(|password| password.expose_secret().to_owned())
|
||||
.unwrap_or_default(),
|
||||
display_name: config.bootstrap_display_name.clone(),
|
||||
},
|
||||
};
|
||||
let runtime_limits = RuntimeLimits::from_env()?;
|
||||
let cache_config = RuntimeCacheConfig::from_env()?;
|
||||
let runtime_limits = RuntimeLimits::try_new(
|
||||
config.runtime.max_concurrent_unary,
|
||||
config.runtime.max_concurrent_sessions,
|
||||
)?;
|
||||
let cache_config = runtime_cache_config(&config)?;
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::from_env()?;
|
||||
let api_rate_limit = RequestRateLimitConfig::new(
|
||||
config.rate_limit.requests_per_second,
|
||||
config.rate_limit.burst,
|
||||
)?;
|
||||
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_response_cache(cache_stores.response.clone())
|
||||
@@ -99,21 +218,23 @@ async fn run(
|
||||
let identity_provider =
|
||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
registry.clone(),
|
||||
config.storage_root.clone(),
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
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))
|
||||
.build();
|
||||
let invocation_log_retention_days = invocation_log_retention_days_from_env()?;
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
if config.demo_seed {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
spawn_invocation_log_cleanup(service.clone(), invocation_log_retention_days);
|
||||
spawn_artifact_reconciliation(registry, artifact_store).await;
|
||||
spawn_invocation_log_cleanup(service.clone(), config.invocation_log_retention_days);
|
||||
let state = AppState {
|
||||
service,
|
||||
api_rate_limiter: if cache_config.backend.is_external() {
|
||||
@@ -121,10 +242,10 @@ async fn run(
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"),
|
||||
trusted_proxy_ips: config.trusted_proxy_ips.clone(),
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
|
||||
|
||||
info!(
|
||||
@@ -138,14 +259,10 @@ async fn run(
|
||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
invocation_log_retention_days,
|
||||
invocation_log_retention_days = config.invocation_log_retention_days,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!(
|
||||
name: "admin.server.listening",
|
||||
bind_address = %socket_addr,
|
||||
"admin-api listening"
|
||||
);
|
||||
info!(name: "admin.server.listening", bind_address = %config.bind_addr, "admin-api listening");
|
||||
*startup_completed = true;
|
||||
|
||||
if let Some(metrics_server) = metrics_server {
|
||||
@@ -156,23 +273,211 @@ async fn run(
|
||||
} else {
|
||||
axum::serve(listener, make_service).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invocation_log_retention_days_from_env() -> Result<i64, Box<dyn std::error::Error>> {
|
||||
const NAME: &str = "CRANK_INVOCATION_LOG_RETENTION_DAYS";
|
||||
let value = match env::var(NAME) {
|
||||
Ok(raw) => raw.parse::<i64>()?,
|
||||
Err(env::VarError::NotPresent) => 30,
|
||||
Err(error) => return Err(error.into()),
|
||||
fn init_observability(
|
||||
config: &ObservabilitySettings,
|
||||
) -> Result<ObservabilityLifecycle, Box<dyn std::error::Error>> {
|
||||
let identity = ServiceIdentity::try_new(
|
||||
"admin-api",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
config.environment.clone(),
|
||||
)?;
|
||||
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
|
||||
};
|
||||
if !(1..=MAX_INVOCATION_LOG_RETENTION_DAYS).contains(&value) {
|
||||
return Err(
|
||||
format!("{NAME} must be between 1 and {MAX_INVOCATION_LOG_RETENTION_DAYS}").into(),
|
||||
);
|
||||
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(
|
||||
config: &DatabaseSettings,
|
||||
) -> Result<PostgresPoolConfig, crank_registry::PostgresPoolConfigError> {
|
||||
PostgresPoolConfig::try_new(
|
||||
config.pool.max_connections,
|
||||
config.pool.min_connections,
|
||||
config.pool.acquire_timeout_ms,
|
||||
config.pool.idle_timeout_ms,
|
||||
config.pool.max_lifetime_ms,
|
||||
)
|
||||
}
|
||||
|
||||
fn database_options(
|
||||
config: &DatabaseSettings,
|
||||
) -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Some(url) = &config.url {
|
||||
return url
|
||||
.expose_secret()
|
||||
.parse::<PgConnectOptions>()
|
||||
.map_err(|_| io::Error::other("database URL is invalid").into());
|
||||
}
|
||||
Ok(value)
|
||||
Ok(PgConnectOptions::new()
|
||||
.host(&config.host)
|
||||
.port(config.port)
|
||||
.database(&config.database)
|
||||
.username(&config.username)
|
||||
.password(config.password.expose_secret()))
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -182,12 +487,23 @@ fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, reten
|
||||
interval.tick().await;
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
|
||||
match service.cleanup_invocation_logs_before(cutoff).await {
|
||||
Ok(removed) if removed > 0 => info!(
|
||||
Ok(outcome) if outcome.deleted_records > 0 => info!(
|
||||
name: "admin.invocation_log_cleanup.completed",
|
||||
removed,
|
||||
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(_) => {}
|
||||
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",
|
||||
@@ -197,50 +513,3 @@ fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, reten
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_api_rate_limit_config_from_env()
|
||||
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> {
|
||||
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(30);
|
||||
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||
}
|
||||
|
||||
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
|
||||
return Ok(database_url.parse::<PgConnectOptions>()?);
|
||||
}
|
||||
|
||||
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()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.database(&database)
|
||||
.username(&username)
|
||||
.password(&password))
|
||||
}
|
||||
|
||||
@@ -10,20 +10,23 @@ use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientIdentityBucket(pub String);
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let peer_ip = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(address)| address.ip());
|
||||
let key = rate_limit_key(
|
||||
let key = client_rate_limit_key(
|
||||
request.headers(),
|
||||
request.uri().path(),
|
||||
peer_ip,
|
||||
state.trust_forwarded_headers,
|
||||
&state.trusted_proxy_ips,
|
||||
);
|
||||
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
||||
return match error {
|
||||
@@ -37,6 +40,8 @@ pub async fn apply_api_rate_limit(
|
||||
};
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(ClientIdentityBucket(key));
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -46,13 +51,15 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(
|
||||
pub fn client_rate_limit_key(
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
peer_ip: Option<IpAddr>,
|
||||
trust_forwarded_headers: bool,
|
||||
trusted_proxy_ips: &[IpAddr],
|
||||
) -> String {
|
||||
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -63,7 +70,8 @@ fn rate_limit_key(
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
||||
/// Resolves the client IP from proxy headers after the immediate peer was
|
||||
/// matched against the trusted-proxy allowlist.
|
||||
///
|
||||
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
||||
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
||||
@@ -96,7 +104,7 @@ mod tests {
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
use super::client_rate_limit_key;
|
||||
|
||||
fn peer() -> Option<IpAddr> {
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
||||
@@ -112,7 +120,7 @@ mod tests {
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.5"
|
||||
);
|
||||
}
|
||||
@@ -124,7 +132,23 @@ mod tests {
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -139,7 +163,7 @@ mod tests {
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.9"
|
||||
);
|
||||
}
|
||||
@@ -155,7 +179,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.6"
|
||||
);
|
||||
}
|
||||
@@ -165,7 +189,7 @@ mod tests {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
@@ -180,7 +204,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
@@ -190,7 +214,7 @@ mod tests {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", None, false),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
|
||||
"anonymous:/api/auth/login"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
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;
|
||||
@@ -0,0 +1,690 @@
|
||||
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");
|
||||
}
|
||||
@@ -4,20 +4,31 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_observability::{RequestId, set_remote_trace_parent, with_request_correlation};
|
||||
use crank_core::{CorrelationContext, RequestId, TraceContext};
|
||||
use crank_metrics::ExemplarTraceId;
|
||||
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 TRACE_ID_HEADER: HeaderName = HeaderName::from_static("x-trace-id");
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
pub request_id: String,
|
||||
pub correlation: CorrelationContext,
|
||||
}
|
||||
|
||||
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 {
|
||||
let context = RequestContext {
|
||||
request_id: RequestId::resolve_from_headers(request.headers()).into_string(),
|
||||
};
|
||||
let (request_id, remote_parent) = resolve_correlation(request.headers());
|
||||
let method = request.method().clone();
|
||||
let route = request
|
||||
.extensions()
|
||||
@@ -27,41 +38,107 @@ pub async fn apply_request_context(mut request: Request, next: Next) -> Response
|
||||
let span = info_span!(
|
||||
target: "crank::trace",
|
||||
"http.request",
|
||||
request_id = %context.request_id,
|
||||
request_id = %request_id,
|
||||
trace_id = tracing::field::Empty,
|
||||
);
|
||||
set_remote_trace_parent(&span, request.headers());
|
||||
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 {
|
||||
correlation: CorrelationContext::new(request_id, trace_context),
|
||||
};
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
with_request_correlation(context.request_id.clone(), async move {
|
||||
let mut response = next.run(request).instrument(span).await;
|
||||
info!(
|
||||
name: "admin.request.completed",
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
route,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
})
|
||||
with_request_correlation(
|
||||
context.correlation.request_id().to_string(),
|
||||
context.correlation.trace_id().to_string(),
|
||||
async move {
|
||||
let mut response = next.run(request).instrument(span).await;
|
||||
info!(
|
||||
name: "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 =
|
||||
one_auxiliary_header_within_budget(headers, "baggage", TraceContext::baggage_within_budget);
|
||||
let mut request_ids = headers.get_all(REQUEST_ID_HEADER).iter();
|
||||
let request_id = request_ids.next().and_then(|value| value.to_str().ok());
|
||||
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(
|
||||
headers: &axum::http::HeaderMap,
|
||||
name: &'static str,
|
||||
validate: fn(&str) -> bool,
|
||||
) -> bool {
|
||||
let mut values = headers.get_all(name).iter();
|
||||
let value = values.next().and_then(|value| value.to_str().ok());
|
||||
values.next().is_none() && value.is_some_and(validate)
|
||||
}
|
||||
|
||||
fn set_canonical_parent(span: &tracing::Span, context: &TraceContext) {
|
||||
crank_trace::set_parent_from_trace_context(span, context);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn accepts_visible_ascii_request_ids() {
|
||||
assert!(crank_observability::RequestId::is_valid("req_test_123"));
|
||||
assert!(crank_observability::RequestId::is_valid("trace-123/abc"));
|
||||
assert!(crank_core::RequestId::is_valid("req_test_123"));
|
||||
assert!(crank_core::RequestId::is_valid("trace-123/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_control_request_ids() {
|
||||
assert!(!crank_observability::RequestId::is_valid(""));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad value"));
|
||||
assert!(!crank_observability::RequestId::is_valid("bad\nvalue"));
|
||||
assert!(!crank_core::RequestId::is_valid(""));
|
||||
assert!(!crank_core::RequestId::is_valid("bad value"));
|
||||
assert!(!crank_core::RequestId::is_valid("bad\nvalue"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod imports;
|
||||
pub mod observability;
|
||||
pub mod onboarding;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
pub mod upstreams;
|
||||
@@ -23,23 +24,89 @@ pub async fn health() -> Json<serde_json::Value> {
|
||||
}
|
||||
|
||||
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.service.readiness().await {
|
||||
Ok(()) => (
|
||||
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": "ready" }
|
||||
"checks": { "postgres": postgres, "artifact_storage": artifact_storage }
|
||||
})),
|
||||
),
|
||||
Err(error) => (
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "not_ready",
|
||||
"checks": { "postgres": "not_ready" },
|
||||
"error": error.to_string()
|
||||
"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,15 +1,19 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{
|
||||
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
|
||||
PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -77,7 +81,7 @@ pub async fn create_agent(
|
||||
pub async fn get_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let agent = state
|
||||
.service
|
||||
.get_agent(
|
||||
@@ -85,20 +89,30 @@ pub async fn get_agent(
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(agent)))
|
||||
let etag = crate::service::AdminService::agent_state_etag(&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(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateAgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.update_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -107,12 +121,15 @@ pub async fn update_agent(
|
||||
pub async fn delete_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let deleted = state
|
||||
.service
|
||||
.delete_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(deleted)))
|
||||
@@ -136,30 +153,79 @@ pub async fn get_agent_version(
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AgentCatalogPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let record = state
|
||||
.service
|
||||
.save_agent_bindings(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
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(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let published = state
|
||||
.service
|
||||
.publish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload.version,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
@@ -168,12 +234,15 @@ pub async fn publish_agent(
|
||||
pub async fn unpublish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.unpublish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -182,12 +251,15 @@ pub async fn unpublish_agent(
|
||||
pub async fn archive_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.archive_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -210,14 +282,19 @@ pub async fn list_agent_platform_api_keys(
|
||||
pub async fn create_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let created = state
|
||||
.service
|
||||
.create_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
@@ -226,13 +303,18 @@ pub async fn create_agent_platform_api_key(
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.revoke_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
@@ -241,13 +323,18 @@ pub async fn revoke_agent_platform_api_key(
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.delete_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
|
||||
@@ -5,18 +5,52 @@ use serde_json::json;
|
||||
use crate::{
|
||||
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||
error::ApiError,
|
||||
rate_limit::ClientIdentityBucket,
|
||||
service::{
|
||||
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
|
||||
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
},
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
client_bucket: Option<Extension<ClientIdentityBucket>>,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.login(payload).await?;
|
||||
let client_bucket = client_bucket
|
||||
.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!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
@@ -54,6 +88,22 @@ pub async fn get_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(
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
@@ -73,6 +123,7 @@ pub async fn update_profile(
|
||||
.service
|
||||
.update_profile(
|
||||
&session.user.id,
|
||||
&session.session_id,
|
||||
session.current_workspace_id.as_ref(),
|
||||
payload,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{AdminAuditContext, AuthProfilePayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
@@ -32,11 +38,19 @@ pub async fn list_auth_profiles(
|
||||
pub async fn create_auth_profile(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<AuthProfilePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let profile = state
|
||||
.service
|
||||
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
|
||||
.create_auth_profile(
|
||||
&path.workspace_id.as_str().into(),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
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, OpenApiImportPreviewPayload},
|
||||
service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -25,27 +28,194 @@ pub struct WorkspaceImportPath {
|
||||
pub async fn preview_openapi_import(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportPreviewPayload>,
|
||||
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(), payload)
|
||||
.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(
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{
|
||||
HeaderValue, StatusCode,
|
||||
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -44,7 +49,49 @@ pub async fn list_logs(
|
||||
.service
|
||||
.list_logs(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
Ok(Json(json!(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(
|
||||
@@ -87,6 +134,38 @@ pub async fn get_approval(
|
||||
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(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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,9 +1,10 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, Query, State},
|
||||
extract::{Extension, Path, Query, State, rejection::StringRejection},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use crank_registry::OperationStateExpectation;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -79,7 +80,7 @@ pub async fn analyze_operation_quality(
|
||||
pub async fn get_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let operation = state
|
||||
.service
|
||||
.get_operation(
|
||||
@@ -87,20 +88,30 @@ pub async fn get_operation(
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(operation)))
|
||||
let etag = crate::service::AdminService::operation_state_etag(&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(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateOperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
||||
let result = state
|
||||
.service
|
||||
.update_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
&expected,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
@@ -109,12 +120,15 @@ pub async fn update_operation(
|
||||
pub async fn delete_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
||||
let result = state
|
||||
.service
|
||||
.delete_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
&expected,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
@@ -123,7 +137,7 @@ pub async fn delete_operation(
|
||||
pub async fn get_operation_version(
|
||||
Path(path): Path<WorkspaceOperationVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_operation_version(
|
||||
@@ -132,20 +146,30 @@ pub async fn get_operation_version(
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
let etag = crate::service::AdminService::operation_version_etag(&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(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<NewVersionPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
||||
let created = state
|
||||
.service
|
||||
.create_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
&expected,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
@@ -154,14 +178,17 @@ pub async fn create_version(
|
||||
pub async fn publish_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
||||
let published = state
|
||||
.service
|
||||
.publish_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload.version,
|
||||
&expected,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
@@ -170,17 +197,63 @@ pub async fn publish_operation(
|
||||
pub async fn archive_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected = require_operation_precondition(&state, &path, &headers).await?;
|
||||
let archived = state
|
||||
.service
|
||||
.archive_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
&expected,
|
||||
)
|
||||
.await?;
|
||||
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(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -193,7 +266,7 @@ pub async fn run_test(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
&request_context.request_id,
|
||||
&request_context.correlation,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
@@ -285,11 +358,33 @@ pub async fn import_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<ImportQuery>,
|
||||
State(state): State<AppState>,
|
||||
body: String,
|
||||
headers: HeaderMap,
|
||||
body: Result<String, StringRejection>,
|
||||
) -> 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
|
||||
.service
|
||||
.import_operation(&path.workspace_id.as_str().into(), query, &body)
|
||||
.import_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
query,
|
||||
&body,
|
||||
headers
|
||||
.get(header::IF_MATCH)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{RotateSecretPayload, SecretPayload},
|
||||
request_context::RequestContext,
|
||||
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -38,14 +39,18 @@ pub async fn create_secret(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<SecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let secret = state
|
||||
.service
|
||||
.create_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
@@ -69,8 +74,11 @@ pub async fn rotate_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<RotateSecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let secret = state
|
||||
.service
|
||||
.rotate_secret(
|
||||
@@ -78,6 +86,7 @@ pub async fn rotate_secret(
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
@@ -86,12 +95,17 @@ pub async fn rotate_secret(
|
||||
pub async fn delete_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.delete_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
|
||||
+478
-101
@@ -3,24 +3,30 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_artifacts::ArtifactStore;
|
||||
use crank_config::ExternalReferenceSettings;
|
||||
use crank_core::{
|
||||
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
|
||||
AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile,
|
||||
CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities,
|
||||
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
|
||||
InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
|
||||
ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
|
||||
ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
|
||||
ProductEdition, Protocol, SecretStatus, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
|
||||
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
||||
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryError, RegistryOperation,
|
||||
UsageBucket,
|
||||
};
|
||||
use crank_runtime::{
|
||||
OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
|
||||
ExternalReferenceFetcher, OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor,
|
||||
SecretCrypto,
|
||||
};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
|
||||
use serde_json::{Value, json};
|
||||
use rand::RngExt;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::Instrument;
|
||||
@@ -33,6 +39,7 @@ mod demo;
|
||||
mod import_export;
|
||||
mod imports;
|
||||
mod observability;
|
||||
mod onboarding;
|
||||
mod operation_validation;
|
||||
mod operations;
|
||||
mod samples;
|
||||
@@ -40,7 +47,11 @@ mod secrets;
|
||||
mod upstreams;
|
||||
mod workspaces;
|
||||
|
||||
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
|
||||
use crate::{
|
||||
auth::{AuthSettings, AuthenticatedSession},
|
||||
error::ApiError,
|
||||
storage::LocalArtifactStorage,
|
||||
};
|
||||
use operation_validation::{
|
||||
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
|
||||
validate_protocol_target, validate_response_cache_policy,
|
||||
@@ -49,6 +60,7 @@ use operation_validation::{
|
||||
#[derive(Clone)]
|
||||
pub struct AdminService {
|
||||
registry: PostgresRegistry,
|
||||
artifact_store: Arc<ArtifactStore>,
|
||||
runtime: RuntimeExecutor,
|
||||
storage: LocalArtifactStorage,
|
||||
auth_settings: AuthSettings,
|
||||
@@ -58,11 +70,16 @@ pub struct AdminService {
|
||||
audit_sink: Arc<dyn AuditSink>,
|
||||
capability_profile: Arc<dyn CapabilityProfile>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
external_reference_fetcher: ExternalReferenceFetcher,
|
||||
external_reference_normalization: crank_import::rest::NormalizationConfig,
|
||||
external_reference_materialization_timeout: std::time::Duration,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
pub struct AdminServiceBuilder {
|
||||
registry: PostgresRegistry,
|
||||
storage_root: PathBuf,
|
||||
artifact_store: Option<Arc<ArtifactStore>>,
|
||||
auth_settings: AuthSettings,
|
||||
secret_crypto: SecretCrypto,
|
||||
runtime: RuntimeExecutor,
|
||||
@@ -71,14 +88,72 @@ pub struct AdminServiceBuilder {
|
||||
audit_sink: Option<Arc<dyn AuditSink>>,
|
||||
capability_profile: Option<Arc<dyn CapabilityProfile>>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
external_reference_fetcher: ExternalReferenceFetcher,
|
||||
external_reference_normalization: crank_import::rest::NormalizationConfig,
|
||||
external_reference_settings: ExternalReferenceSettings,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
pub use crate::dto::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ReadinessChecks {
|
||||
pub postgres: bool,
|
||||
pub artifact_storage: bool,
|
||||
}
|
||||
|
||||
impl ReadinessChecks {
|
||||
pub const fn is_ready(self) -> bool {
|
||||
self.postgres && self.artifact_storage
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminAuditContext {
|
||||
actor: AuditActor,
|
||||
request_id: String,
|
||||
trace_id: String,
|
||||
}
|
||||
|
||||
impl AdminAuditContext {
|
||||
pub fn from_session_and_correlation(
|
||||
session: &AuthenticatedSession,
|
||||
correlation: &CorrelationContext,
|
||||
) -> Self {
|
||||
Self {
|
||||
actor: AuditActor {
|
||||
user_id: session.user.id.clone(),
|
||||
email: session.user.email.clone(),
|
||||
session_id: Some(session.session_id.clone()),
|
||||
},
|
||||
request_id: correlation.request_id().as_str().to_owned(),
|
||||
trace_id: correlation.trace_id().as_str().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct CredentialAuditRecord<'a> {
|
||||
pub action: &'static str,
|
||||
pub target_kind: AuditTargetKind,
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub target_id: &'a str,
|
||||
pub credential_type: &'static str,
|
||||
pub outcome: &'static str,
|
||||
pub reason: &'a str,
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub async fn readiness(&self) -> Result<(), ApiError> {
|
||||
self.registry.ping().await?;
|
||||
Ok(())
|
||||
pub async fn readiness(&self) -> ReadinessChecks {
|
||||
let postgres = self.registry.ping().await.is_ok();
|
||||
let store = Arc::clone(&self.artifact_store);
|
||||
let artifact_storage = tokio::task::spawn_blocking(move || store.check_health())
|
||||
.await
|
||||
.is_ok_and(|result| result.is_ok());
|
||||
ReadinessChecks {
|
||||
postgres,
|
||||
artifact_storage,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -132,6 +207,19 @@ impl AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_credential_error_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::AuthorizationStoreUnavailable => "authorization_store_unavailable",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::InvalidAuthProfileConfig { .. } => "auth_profile_invalid",
|
||||
RuntimeError::MissingSecret { .. } => "secret_not_found",
|
||||
RuntimeError::MissingSecretVersion { .. } => "secret_version_not_found",
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_invalid",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_failed",
|
||||
_ => "credential_resolution_failed",
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminServiceBuilder {
|
||||
pub fn new(
|
||||
registry: PostgresRegistry,
|
||||
@@ -140,9 +228,26 @@ impl AdminServiceBuilder {
|
||||
secret_crypto: SecretCrypto,
|
||||
runtime: RuntimeExecutor,
|
||||
) -> Self {
|
||||
let outbound_http_policy = OutboundHttpPolicy::default();
|
||||
let external_reference_settings = ExternalReferenceSettings {
|
||||
allowed_url_prefixes: Vec::new(),
|
||||
max_depth: 8,
|
||||
max_documents: 32,
|
||||
max_fetch_bytes: 256 * 1024,
|
||||
fetch_timeout_ms: 5_000,
|
||||
max_expanded_nodes: 10_000,
|
||||
};
|
||||
let external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
outbound_http_policy.clone(),
|
||||
external_reference_settings.allowed_url_prefixes.clone(),
|
||||
external_reference_settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(external_reference_settings.fetch_timeout_ms),
|
||||
)
|
||||
.expect("static external reference defaults are valid");
|
||||
Self {
|
||||
registry,
|
||||
storage_root,
|
||||
artifact_store: None,
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
runtime,
|
||||
@@ -150,7 +255,11 @@ impl AdminServiceBuilder {
|
||||
policy_engine: None,
|
||||
audit_sink: None,
|
||||
capability_profile: None,
|
||||
outbound_http_policy: OutboundHttpPolicy::default(),
|
||||
outbound_http_policy,
|
||||
external_reference_fetcher,
|
||||
external_reference_normalization: Default::default(),
|
||||
external_reference_settings,
|
||||
public_base_url: "http://localhost:3000".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +269,54 @@ impl AdminServiceBuilder {
|
||||
}
|
||||
|
||||
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
|
||||
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
policy.clone(),
|
||||
self.external_reference_settings
|
||||
.allowed_url_prefixes
|
||||
.clone(),
|
||||
self.external_reference_settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(self.external_reference_settings.fetch_timeout_ms),
|
||||
)
|
||||
.expect("validated external reference settings remain valid");
|
||||
self.outbound_http_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_external_reference_import(
|
||||
mut self,
|
||||
settings: &ExternalReferenceSettings,
|
||||
) -> Result<Self, crank_runtime::ExternalReferenceFetchError> {
|
||||
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
self.outbound_http_policy.clone(),
|
||||
settings.allowed_url_prefixes.clone(),
|
||||
settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(settings.fetch_timeout_ms),
|
||||
)?;
|
||||
self.external_reference_normalization.max_reference_depth = settings.max_depth;
|
||||
self.external_reference_normalization
|
||||
.max_reference_documents = settings.max_documents;
|
||||
self.external_reference_normalization
|
||||
.max_external_document_bytes = settings.max_fetch_bytes;
|
||||
self.external_reference_normalization.max_expanded_nodes = settings.max_expanded_nodes;
|
||||
self.external_reference_normalization
|
||||
.external_references_enabled = !settings.allowed_url_prefixes.is_empty();
|
||||
self.external_reference_settings = settings.clone();
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Reuses the process-wide immutable artifact authority for OpenAPI
|
||||
/// ingress and reconciliation. Tests may omit this and use their private
|
||||
/// storage root instead.
|
||||
pub fn with_artifact_store(mut self, artifact_store: Arc<ArtifactStore>) -> Self {
|
||||
self.artifact_store = Some(artifact_store);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_public_base_url(mut self, public_base_url: String) -> Self {
|
||||
self.public_base_url = public_base_url.trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
|
||||
self.policy_engine = Some(policy_engine);
|
||||
@@ -188,6 +341,9 @@ impl AdminServiceBuilder {
|
||||
pub fn build(self) -> AdminService {
|
||||
AdminService {
|
||||
registry: self.registry,
|
||||
artifact_store: self
|
||||
.artifact_store
|
||||
.unwrap_or_else(|| Arc::new(ArtifactStore::new(&self.storage_root))),
|
||||
runtime: self.runtime,
|
||||
storage: LocalArtifactStorage::new(self.storage_root),
|
||||
auth_settings: self.auth_settings,
|
||||
@@ -201,6 +357,12 @@ impl AdminServiceBuilder {
|
||||
.capability_profile
|
||||
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
|
||||
outbound_http_policy: self.outbound_http_policy,
|
||||
external_reference_fetcher: self.external_reference_fetcher,
|
||||
external_reference_normalization: self.external_reference_normalization,
|
||||
external_reference_materialization_timeout: std::time::Duration::from_millis(
|
||||
self.external_reference_settings.fetch_timeout_ms,
|
||||
),
|
||||
public_base_url: self.public_base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,10 +416,56 @@ impl AdminService {
|
||||
self.capability_profile().capabilities()
|
||||
}
|
||||
|
||||
async fn record_credential_audit(
|
||||
&self,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
record: CredentialAuditRecord<'_>,
|
||||
) {
|
||||
let Some(audit_context) = audit_context else {
|
||||
return;
|
||||
};
|
||||
|
||||
let event = AuditEvent {
|
||||
id: AuditEventId::new(new_prefixed_id("audit")),
|
||||
occurred_at: OffsetDateTime::now_utc(),
|
||||
actor: audit_context.actor.clone(),
|
||||
action: record.action.to_owned(),
|
||||
target: AuditTarget {
|
||||
workspace_id: record.workspace_id.clone(),
|
||||
kind: record.target_kind,
|
||||
id: record.target_id.to_owned(),
|
||||
},
|
||||
payload: json!({
|
||||
"credential_type": record.credential_type,
|
||||
"outcome": record.outcome,
|
||||
"reason": record.reason,
|
||||
"request_id": audit_context.request_id,
|
||||
"trace_id": audit_context.trace_id
|
||||
}),
|
||||
source_ip: None,
|
||||
user_agent: None,
|
||||
};
|
||||
|
||||
if self.audit_sink.record(event).await.is_err() {
|
||||
tracing::warn!(
|
||||
name: "admin.credential_audit.lost",
|
||||
action = record.action,
|
||||
credential_type = record.credential_type,
|
||||
outcome = record.outcome,
|
||||
reason = record.reason,
|
||||
request_id = %audit_context.request_id,
|
||||
trace_id = %audit_context.trace_id,
|
||||
error_code = "audit_sink_failed",
|
||||
"credential audit event was not recorded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_operation_auth(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
|
||||
return Ok(None);
|
||||
@@ -271,15 +479,14 @@ impl AdminService {
|
||||
.get_auth_profile(workspace_id, auth_profile_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load auth profile",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
})?;
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
|
||||
let Some(auth_profile) = auth_profile else {
|
||||
return Err(RuntimeError::MissingAuthProfile {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
self.resolve_auth_profile(workspace_id, &auth_profile)
|
||||
self.resolve_auth_profile(workspace_id, &auth_profile, audit_context)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
@@ -292,6 +499,17 @@ impl AdminService {
|
||||
ErrorCategory::Configuration.record(&span);
|
||||
}
|
||||
}
|
||||
if let Err(error) = &result {
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::AuthProfile,
|
||||
auth_profile_id.as_str(),
|
||||
"auth_profile",
|
||||
runtime_credential_error_code(error),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -299,6 +517,7 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
auth_profile: &AuthProfile,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<ResolvedAuth, RuntimeError> {
|
||||
let mut secrets = BTreeMap::new();
|
||||
let used_at = OffsetDateTime::now_utc();
|
||||
@@ -309,45 +528,156 @@ impl AdminService {
|
||||
self.registry.get_secret(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load secret",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
|
||||
let Some(secret) = secret else {
|
||||
let error = RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
};
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::Secret,
|
||||
secret_id.as_str(),
|
||||
"secret",
|
||||
runtime_credential_error_code(&error),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
};
|
||||
if secret.secret.status != SecretStatus::Active {
|
||||
let error = RuntimeError::InvalidAuthSecretValue {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
reason: "secret is not active".to_owned(),
|
||||
};
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::Secret,
|
||||
secret_id.as_str(),
|
||||
"secret",
|
||||
runtime_credential_error_code(&error),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
let version = observe_db_query(
|
||||
DbOperation::SecretRead,
|
||||
self.registry
|
||||
.get_current_secret_version(workspace_id, secret_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "load current secret version",
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let plaintext = self.secret_crypto.decrypt(
|
||||
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
|
||||
let Some(version) = version else {
|
||||
let error = RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
};
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::Secret,
|
||||
secret_id.as_str(),
|
||||
"secret",
|
||||
runtime_credential_error_code(&error),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
};
|
||||
let plaintext = self.secret_crypto.decrypt_for_epoch(
|
||||
&version.secret_version.key_version,
|
||||
version.master_key_epoch,
|
||||
&version.secret_version.ciphertext,
|
||||
)?;
|
||||
);
|
||||
let plaintext = match plaintext {
|
||||
Ok(plaintext) => plaintext,
|
||||
Err(error) => {
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::Secret,
|
||||
secret_id.as_str(),
|
||||
"secret",
|
||||
runtime_credential_error_code(&error),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
observe_db_query(
|
||||
DbOperation::SecretTouch,
|
||||
self.registry
|
||||
.touch_secret(workspace_id, secret_id, &used_at),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
operation: "touch secret",
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
name: "admin.secret.touch_failed",
|
||||
secret_id = %secret_id.as_str(),
|
||||
request_id = audit_context
|
||||
.map(|context| context.request_id.as_str())
|
||||
.unwrap_or("unavailable"),
|
||||
trace_id = audit_context
|
||||
.map(|context| context.trace_id.as_str())
|
||||
.unwrap_or("unavailable"),
|
||||
error_code = "secret_touch_failed",
|
||||
"secret last-used metadata was not updated"
|
||||
);
|
||||
});
|
||||
secrets.insert(secret_id.clone(), plaintext);
|
||||
}
|
||||
|
||||
ResolvedAuth::from_profile(auth_profile, &secrets)
|
||||
let result = ResolvedAuth::from_profile(auth_profile, &secrets);
|
||||
if let Err(error) = &result {
|
||||
self.record_credential_resolution_failure(
|
||||
audit_context,
|
||||
workspace_id,
|
||||
crank_core::AuditTargetKind::AuthProfile,
|
||||
auth_profile.id.as_str(),
|
||||
"auth_profile",
|
||||
runtime_credential_error_code(error),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn record_credential_resolution_failure(
|
||||
&self,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
workspace_id: &WorkspaceId,
|
||||
target_kind: crank_core::AuditTargetKind,
|
||||
target_id: &str,
|
||||
credential_type: &'static str,
|
||||
reason: &'static str,
|
||||
) {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.resolve_denied",
|
||||
target_kind,
|
||||
workspace_id,
|
||||
target_id,
|
||||
credential_type,
|
||||
outcome: "failure",
|
||||
reason,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tracing::warn!(
|
||||
name: "admin.credential.resolve_denied",
|
||||
credential_type,
|
||||
target_kind = ?target_kind,
|
||||
target_id,
|
||||
outcome = "failure",
|
||||
reason,
|
||||
request_id = audit_context
|
||||
.map(|context| context.request_id.as_str())
|
||||
.unwrap_or("unavailable"),
|
||||
trace_id = audit_context
|
||||
.map(|context| context.trace_id.as_str())
|
||||
.unwrap_or("unavailable"),
|
||||
"credential resolution was denied"
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
|
||||
@@ -376,6 +706,70 @@ impl AdminService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_registry_operation_in_workspace(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation: &RegistryOperation,
|
||||
) -> Result<(), ApiError> {
|
||||
self.validate_registry_operation(operation)?;
|
||||
if let Some(auth_profile_id) = operation.execution_config.auth_profile_ref.as_ref() {
|
||||
let Some(auth_profile) = self
|
||||
.registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation auth profile reference is unavailable",
|
||||
json!({ "error_code": "operation_auth_profile_invalid" }),
|
||||
));
|
||||
};
|
||||
self.validate_auth_profile_secret_refs_for_execution(workspace_id, &auth_profile)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_auth_profile_secret_refs_for_execution(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
auth_profile: &AuthProfile,
|
||||
) -> Result<(), ApiError> {
|
||||
if auth_profile.kind != auth_profile.config.kind() {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation auth profile kind and config do not match",
|
||||
json!({ "error_code": "operation_auth_profile_invalid" }),
|
||||
));
|
||||
}
|
||||
|
||||
for secret_id in auth_profile.config.secret_ids() {
|
||||
let Some(secret) = self.registry.get_secret(workspace_id, secret_id).await? else {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation auth profile secret reference is unavailable",
|
||||
json!({ "error_code": "operation_auth_profile_invalid" }),
|
||||
));
|
||||
};
|
||||
if secret.secret.status != SecretStatus::Active {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if self
|
||||
.registry
|
||||
.get_current_secret_version(workspace_id, secret_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation auth profile secret has no current version",
|
||||
json!({ "error_code": "operation_auth_profile_invalid" }),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
|
||||
match target {
|
||||
crank_core::Target::Rest(rest) => self
|
||||
@@ -462,16 +856,23 @@ impl AdminService {
|
||||
id: InvocationLogId::new(new_prefixed_id("log")),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
agent_id: request.agent_id.cloned(),
|
||||
platform_api_key_id: None,
|
||||
operation_id: request.operation.id.clone(),
|
||||
operation_version: Some(request.operation.version),
|
||||
source: request.source,
|
||||
level: request.level,
|
||||
status: request.status,
|
||||
tool_name: request.operation.name.clone(),
|
||||
message: request.message,
|
||||
request_id: request.request_id.map(ToOwned::to_owned),
|
||||
trace_id: request.trace_id.map(ToOwned::to_owned),
|
||||
status_code: request.status_code,
|
||||
duration_ms: request.duration_ms,
|
||||
error_kind: request.error_kind,
|
||||
execution_stage: request.execution_stage,
|
||||
execution_error_code: request.execution_error_code,
|
||||
retryability: request.retryability,
|
||||
outcome_certainty: request.outcome_certainty,
|
||||
request_preview: request.request_preview,
|
||||
response_preview: request.response_preview,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
@@ -506,6 +907,7 @@ impl AdminService {
|
||||
observe_invocation_history_outcome(
|
||||
outcome,
|
||||
request.request_id,
|
||||
request.trace_id,
|
||||
request.status,
|
||||
request.source,
|
||||
);
|
||||
@@ -516,6 +918,7 @@ impl AdminService {
|
||||
fn observe_invocation_history_outcome(
|
||||
outcome: InvocationHistoryWriteOutcome,
|
||||
request_id: Option<&str>,
|
||||
trace_id: Option<&str>,
|
||||
status: crank_core::InvocationStatus,
|
||||
source: InvocationSource,
|
||||
) {
|
||||
@@ -528,6 +931,7 @@ fn observe_invocation_history_outcome(
|
||||
tracing::warn!(
|
||||
name: "admin.invocation_history.lost",
|
||||
request_id = request_id.unwrap_or_default(),
|
||||
trace_id = trace_id.unwrap_or_default(),
|
||||
source = invocation_source_label(source),
|
||||
invocation_status = invocation_status_label(status),
|
||||
error_category = loss.category.as_str(),
|
||||
@@ -549,29 +953,6 @@ fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request_preview(
|
||||
mapping: &MappingSet,
|
||||
input: &Value,
|
||||
) -> Result<Value, crank_mapping::MappingError> {
|
||||
let prepared = if mapping.is_empty() {
|
||||
PreparedRequest::default()
|
||||
} else {
|
||||
let mapped = mapping.apply(&json!({ "mcp": input }))?;
|
||||
PreparedRequest::from_mapping_output(&mapped).map_err(|error| {
|
||||
crank_mapping::MappingError::InvalidJsonPath {
|
||||
path: error.to_string(),
|
||||
}
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"path": prepared.path_params,
|
||||
"query": prepared.query_params,
|
||||
"headers": prepared.headers,
|
||||
"body": prepared.body.unwrap_or(Value::Null)
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
|
||||
let display_name = value.trim();
|
||||
if display_name.is_empty() {
|
||||
@@ -612,7 +993,9 @@ fn new_prefixed_id(prefix: &str) -> String {
|
||||
}
|
||||
|
||||
fn generate_access_secret(marker: &str) -> String {
|
||||
let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes());
|
||||
let mut secret_bytes = [0_u8; 32];
|
||||
rand::rng().fill(&mut secret_bytes);
|
||||
let random = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||
format!("{marker}{random}")
|
||||
}
|
||||
|
||||
@@ -636,7 +1019,7 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String {
|
||||
fn map_identity_error(error: IdentityError) -> ApiError {
|
||||
match error {
|
||||
IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"),
|
||||
IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"),
|
||||
IdentityError::AccountDisabled => ApiError::unauthorized("invalid email or password"),
|
||||
IdentityError::NotSupportedForProvider => ApiError::internal(
|
||||
"password login is not supported by the configured identity provider",
|
||||
),
|
||||
@@ -644,32 +1027,6 @@ fn map_identity_error(error: IdentityError) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "schema_error",
|
||||
RuntimeError::Mapping(_) => "mapping_error",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "invalid_request",
|
||||
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
|
||||
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
|
||||
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
|
||||
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
|
||||
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
|
||||
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
|
||||
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
|
||||
RuntimeError::RestAdapter(_) => "rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "adapter_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"secret_not_found"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_capability_view(
|
||||
protocol: Protocol,
|
||||
_edition: ProductEdition,
|
||||
@@ -694,7 +1051,9 @@ fn protocol_capability_view(
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {
|
||||
fn usage_window(
|
||||
period: UsagePeriod,
|
||||
) -> Result<(UsagePeriod, String, String, UsageBucket), ApiError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let (start, bucket) = match period {
|
||||
UsagePeriod::Last30Minutes => (
|
||||
@@ -742,13 +1101,14 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket
|
||||
),
|
||||
};
|
||||
|
||||
Ok((
|
||||
period,
|
||||
start
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||
bucket,
|
||||
))
|
||||
let created_after = start
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let created_before = now
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
|
||||
Ok((period, created_after, created_before, bucket))
|
||||
}
|
||||
|
||||
fn today_start_utc() -> Result<String, ApiError> {
|
||||
@@ -806,6 +1166,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
|
||||
status: summary.status,
|
||||
current_draft_version: summary.current_draft_version,
|
||||
latest_published_version: summary.latest_published_version,
|
||||
catalog_revision: summary.catalog_revision,
|
||||
created_at: format_timestamp(summary.created_at),
|
||||
updated_at: format_timestamp(summary.updated_at),
|
||||
published_at: summary.published_at.map(format_timestamp),
|
||||
@@ -818,10 +1179,24 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
|
||||
pub(crate) fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
|
||||
format!("/mcp/v1/{workspace_slug}/{agent_slug}")
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub(crate) fn public_agent_mcp_endpoint(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
self.public_base_url,
|
||||
agent_mcp_endpoint(workspace_slug, agent_slug)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_quality_schema_node(schema: &Schema) -> ToolQualitySchemaNode {
|
||||
ToolQualitySchemaNode {
|
||||
kind: tool_quality_schema_kind(&schema.kind),
|
||||
@@ -935,6 +1310,7 @@ mod tests {
|
||||
category: InvocationHistoryLossCategory::InvalidRecord,
|
||||
}),
|
||||
Some("req_admin_dc08"),
|
||||
Some("0af7651916cd43dd8448eb211c80319c"),
|
||||
InvocationStatus::Error,
|
||||
InvocationSource::AgentToolCall,
|
||||
);
|
||||
@@ -1008,6 +1384,7 @@ fn enrich_operation_summary(
|
||||
status: summary.status,
|
||||
current_draft_version: summary.current_draft_version,
|
||||
latest_published_version: summary.latest_published_version,
|
||||
can_delete: summary.can_delete,
|
||||
created_at: format_timestamp(summary.created_at),
|
||||
updated_at: format_timestamp(summary.updated_at),
|
||||
published_at: summary.published_at.map(format_timestamp),
|
||||
|
||||
@@ -5,11 +5,13 @@ use crank_core::{
|
||||
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
||||
};
|
||||
use crank_registry::{
|
||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
|
||||
AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
|
||||
UsageQuery,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
@@ -17,12 +19,50 @@ use crate::{
|
||||
service::{
|
||||
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
|
||||
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,
|
||||
@@ -110,13 +150,16 @@ impl AdminService {
|
||||
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: &today_start_utc()?,
|
||||
created_after: &usage_start,
|
||||
created_before: &usage_end,
|
||||
bucket: UsageBucket::Hour,
|
||||
})
|
||||
.await?;
|
||||
@@ -189,6 +232,8 @@ impl AdminService {
|
||||
.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(
|
||||
@@ -196,7 +241,8 @@ impl AdminService {
|
||||
workspace_id,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: &today_start_utc()?,
|
||||
created_after: &usage_start,
|
||||
created_before: &usage_end,
|
||||
bucket: UsageBucket::Hour,
|
||||
},
|
||||
agent_id,
|
||||
@@ -317,6 +363,7 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: UpdateAgentPayload,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
@@ -328,6 +375,20 @@ impl AdminService {
|
||||
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
|
||||
@@ -346,10 +407,13 @@ impl AdminService {
|
||||
.update_agent_summary(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
&payload.slug,
|
||||
&payload.display_name,
|
||||
&payload.description,
|
||||
&updated_at,
|
||||
UpdateAgentSummaryRequest {
|
||||
slug: &payload.slug,
|
||||
display_name: &payload.display_name,
|
||||
description: &payload.description,
|
||||
updated_at: &updated_at,
|
||||
expected_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -365,6 +429,7 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
@@ -376,8 +441,20 @@ impl AdminService {
|
||||
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).await?;
|
||||
self.registry
|
||||
.delete_agent(workspace_id, agent_id, expected_state)
|
||||
.await?;
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -392,6 +469,7 @@ impl AdminService {
|
||||
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)
|
||||
@@ -408,6 +486,8 @@ impl AdminService {
|
||||
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)?;
|
||||
@@ -419,6 +499,7 @@ impl AdminService {
|
||||
agent_version: current_version.version,
|
||||
bindings: &bindings,
|
||||
tool_selection_policy: &tool_selection_policy,
|
||||
expected_state,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
@@ -474,19 +555,23 @@ impl AdminService {
|
||||
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?;
|
||||
let published_bindings = self
|
||||
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
validate_tool_selection_policy(
|
||||
&agent_version.snapshot.tool_selection_policy,
|
||||
&published_bindings,
|
||||
&agent_version.bindings,
|
||||
)?;
|
||||
|
||||
if published_bindings.is_empty() {
|
||||
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!({
|
||||
@@ -497,36 +582,10 @@ impl AdminService {
|
||||
));
|
||||
}
|
||||
|
||||
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
|
||||
let published_at = OffsetDateTime::now_utc();
|
||||
if published_bindings != agent_version.bindings {
|
||||
let draft_version = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: agent_version.version + 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: agent_version.snapshot.instructions.clone(),
|
||||
tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(),
|
||||
created_at: published_at,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version: &draft_version,
|
||||
bindings: &agent_version.bindings,
|
||||
updated_at: &published_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: agent_version.version,
|
||||
bindings: &published_bindings,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
@@ -535,6 +594,7 @@ impl AdminService {
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
expected_state,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
@@ -552,41 +612,65 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn published_agent_bindings(
|
||||
async fn validate_exact_published_agent_bindings(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<Vec<AgentOperationBinding>, ApiError> {
|
||||
let mut published = Vec::new();
|
||||
|
||||
) -> Result<(), ApiError> {
|
||||
for binding in bindings {
|
||||
if !binding.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(summary) = self
|
||||
.registry
|
||||
.get_operation_summary(workspace_id, &binding.operation_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
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(operation_version) = summary.latest_published_version else {
|
||||
continue;
|
||||
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
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
published.push(AgentOperationBinding {
|
||||
operation_id: summary.id,
|
||||
operation_version,
|
||||
tool_name: binding.tool_name.clone(),
|
||||
tool_title: binding.tool_title.clone(),
|
||||
tool_description_override: binding.tool_description_override.clone(),
|
||||
enabled: true,
|
||||
});
|
||||
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(published)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
@@ -594,11 +678,12 @@ impl AdminService {
|
||||
&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)
|
||||
.unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.unpublished",
|
||||
@@ -618,11 +703,12 @@ impl AdminService {
|
||||
&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)
|
||||
.archive_agent(workspace_id, agent_id, &updated_at, expected_state)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.archived",
|
||||
|
||||
@@ -6,12 +6,14 @@ 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::{
|
||||
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
|
||||
hash_access_secret, new_prefixed_id,
|
||||
AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
|
||||
EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
|
||||
generate_access_secret, hash_access_secret, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,32 +40,84 @@ impl AdminService {
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
|
||||
#[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,
|
||||
payload: PlatformApiKeyPayload,
|
||||
mut payload: PlatformApiKeyPayload,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.registry
|
||||
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_or_else(|| {
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
||||
validate_platform_api_key_payload(&payload)?;
|
||||
|
||||
let expires_at = match payload.expires_at.as_deref() {
|
||||
Some(value) => Some(
|
||||
OffsetDateTime::parse(value, &Rfc3339)
|
||||
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
|
||||
),
|
||||
None => None,
|
||||
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 {
|
||||
@@ -83,55 +137,213 @@ impl AdminService {
|
||||
},
|
||||
};
|
||||
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &api_key.api_key,
|
||||
secret_hash: &hash_access_secret(&secret),
|
||||
})
|
||||
.await?;
|
||||
.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;
|
||||
|
||||
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
|
||||
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), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
#[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> {
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.revoke_platform_api_key_for_agent(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await?;
|
||||
.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), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
|
||||
#[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> {
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
|
||||
.await?;
|
||||
.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 validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
|
||||
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!(
|
||||
@@ -156,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<
|
||||
"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",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
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,20 +1,101 @@
|
||||
use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId};
|
||||
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_session_cookie, hash_password,
|
||||
hash_session_secret, verify_password,
|
||||
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
|
||||
hash_csrf_token, hash_password, hash_session_secret, verify_password,
|
||||
},
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload,
|
||||
map_identity_error, validate_profile_display_name, validate_profile_email,
|
||||
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,
|
||||
@@ -83,36 +164,99 @@ impl AdminService {
|
||||
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> {
|
||||
let authenticated = self.authenticate_login(&payload).await?;
|
||||
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 memberships = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&authenticated.user.id)
|
||||
.await?;
|
||||
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(|| {
|
||||
authenticated
|
||||
.current_workspace_id
|
||||
.as_ref()
|
||||
.map(|workspace_id| workspace_id.as_str().to_owned())
|
||||
.or_else(|| {
|
||||
memberships
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned())
|
||||
})
|
||||
memberships
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned())
|
||||
});
|
||||
let current_workspace_ref = current_workspace_id
|
||||
.as_ref()
|
||||
@@ -120,9 +264,10 @@ impl AdminService {
|
||||
self.registry
|
||||
.create_user_session(
|
||||
&session_cookie.session_id,
|
||||
&authenticated.user.id,
|
||||
&user.id,
|
||||
current_workspace_ref.as_ref(),
|
||||
&secret_hash,
|
||||
Some(&csrf_hash),
|
||||
&session_cookie.expires_at,
|
||||
)
|
||||
.await?;
|
||||
@@ -130,9 +275,10 @@ impl AdminService {
|
||||
Ok((
|
||||
session_cookie,
|
||||
SessionResponse {
|
||||
user: authenticated.user,
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
csrf_token,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -158,7 +304,19 @@ impl AdminService {
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
|
||||
.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,
|
||||
@@ -181,6 +339,8 @@ impl AdminService {
|
||||
_session_value: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry.revoke_user_session(session_id).await?;
|
||||
self.record_admin_security_audit(None, "logout", "success", "session")
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -190,21 +350,23 @@ impl AdminService {
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
) -> Result<Option<SessionResponse>, ApiError> {
|
||||
Ok(self
|
||||
.get_session(session_id, session_value)
|
||||
.await?
|
||||
.map(|session| SessionResponse {
|
||||
user: session.user,
|
||||
memberships: session.memberships,
|
||||
current_workspace_id: session
|
||||
.current_workspace_id
|
||||
.map(|id| id.as_str().to_owned()),
|
||||
}))
|
||||
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> {
|
||||
@@ -221,18 +383,19 @@ impl AdminService {
|
||||
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,
|
||||
_current_session_id: &UserSessionId,
|
||||
payload: ChangePasswordPayload,
|
||||
) -> Result<(), ApiError> {
|
||||
if payload.new_password.len() < 12 {
|
||||
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
|
||||
return Err(ApiError::validation(
|
||||
"new password must be at least 12 characters long",
|
||||
"new password must be between 12 and 256 characters long",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -258,13 +421,48 @@ impl AdminService {
|
||||
let password_hash =
|
||||
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
|
||||
self.registry
|
||||
.update_user_password_and_revoke_other_sessions(
|
||||
user_id,
|
||||
current_session_id,
|
||||
&password_hash,
|
||||
)
|
||||
.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,9 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
|
||||
Protocol, Target, WizardState, WorkspaceId,
|
||||
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};
|
||||
@@ -87,8 +87,12 @@ impl AdminService {
|
||||
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()))
|
||||
.await?;
|
||||
self.delete_agent(
|
||||
workspace_id,
|
||||
&AgentId::new(agent.id.as_str().to_owned()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +133,10 @@ impl AdminService {
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
|
||||
Err(
|
||||
RegistryError::OperationHasPublishedAgentBindings { .. }
|
||||
| RegistryError::OperationDeleteForbidden { .. },
|
||||
) => {
|
||||
tracing::warn!(
|
||||
name: "admin.demo_operation.cleanup_skipped",
|
||||
operation_id = %operation_id.as_str(),
|
||||
@@ -168,6 +175,7 @@ impl AdminService {
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.api_key
|
||||
@@ -175,7 +183,7 @@ impl AdminService {
|
||||
};
|
||||
|
||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
||||
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
|
||||
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -210,8 +218,17 @@ impl AdminService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.publish_operation(workspace_id, &summary.id, summary.current_draft_version)
|
||||
.await?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -273,10 +290,15 @@ impl AdminService {
|
||||
publish: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let summary = self.get_agent(workspace_id, agent_id).await?;
|
||||
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
|
||||
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)
|
||||
self.publish_agent(workspace_id, agent_id, summary.current_draft_version, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -294,11 +316,16 @@ impl AdminService {
|
||||
.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?
|
||||
@@ -316,17 +343,23 @@ impl AdminService {
|
||||
.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: None,
|
||||
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": {},
|
||||
@@ -341,6 +374,25 @@ impl AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
use crank_core::{ConfigExport, WorkspaceId};
|
||||
use crank_registry::RegistryOperation;
|
||||
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, NewVersionPayload,
|
||||
OperationPayload, YamlOperationDocument,
|
||||
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(
|
||||
@@ -30,19 +40,30 @@ impl AdminService {
|
||||
let record = self
|
||||
.get_operation_version(workspace_id, operation_id, version)
|
||||
.await?;
|
||||
let document = YamlOperationDocument {
|
||||
format_version: "1".to_owned(),
|
||||
kind: "operation".to_owned(),
|
||||
operation: RegistryOperation {
|
||||
config_export: Some(ConfigExport {
|
||||
format_version: "1".to_owned(),
|
||||
export_mode: query.mode,
|
||||
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
|
||||
}),
|
||||
..record.snapshot
|
||||
},
|
||||
));
|
||||
}
|
||||
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(|error| ApiError::internal(error.to_string()))
|
||||
serde_yaml::to_string(&document)
|
||||
.map_err(|_| ApiError::internal("portable yaml serialization failed"))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
|
||||
@@ -51,29 +72,41 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
query: ImportQuery,
|
||||
yaml_document: &str,
|
||||
if_match: Option<&str>,
|
||||
) -> Result<ImportResponse, ApiError> {
|
||||
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
if document.kind != "operation" {
|
||||
return Err(ApiError::validation("yaml kind must be operation"));
|
||||
}
|
||||
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 payload = OperationPayload {
|
||||
name: document.operation.name.clone(),
|
||||
display_name: document.operation.display_name.clone(),
|
||||
category: document.operation.category.clone(),
|
||||
protocol: document.operation.protocol,
|
||||
security_level: document.operation.security_level,
|
||||
target: document.operation.target.clone(),
|
||||
input_schema: document.operation.input_schema.clone(),
|
||||
output_schema: document.operation.output_schema.clone(),
|
||||
input_mapping: document.operation.input_mapping.clone(),
|
||||
output_mapping: document.operation.output_mapping.clone(),
|
||||
execution_config: document.operation.execution_config.clone(),
|
||||
tool_description: document.operation.tool_description.clone(),
|
||||
wizard_state: document.operation.wizard_state.clone(),
|
||||
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()),
|
||||
};
|
||||
let warnings = import_guidance_warnings(&document.operation);
|
||||
reject_credential_material(&operation)?;
|
||||
let payload = operation.to_payload();
|
||||
|
||||
match query.mode {
|
||||
ImportMode::Create => {
|
||||
@@ -88,9 +121,66 @@ impl AdminService {
|
||||
}
|
||||
ImportMode::Upsert => {
|
||||
if let Some(existing) = self
|
||||
.find_operation_by_name(workspace_id, &document.operation.name)
|
||||
.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,
|
||||
@@ -99,9 +189,9 @@ impl AdminService {
|
||||
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,
|
||||
@@ -118,22 +208,384 @@ impl AdminService {
|
||||
Ok(response)
|
||||
} else {
|
||||
let created = self.create_operation(workspace_id, payload).await?;
|
||||
let response = ImportResponse {
|
||||
Ok(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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,15 +1,19 @@
|
||||
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, operation_draft_from_candidate,
|
||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, NORMALIZER_VERSION,
|
||||
PROJECTION_VERSION, operation_draft_from_candidate,
|
||||
};
|
||||
use crank_registry::{
|
||||
ApplyImportJobRequest, CreateImportJobRequest, ImportConflictMode, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, ImportOperationDraft,
|
||||
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
|
||||
CreateArtifactSourceRequest, CreateImportJobRequest, FinishImportJobRequest,
|
||||
ImportConflictMode, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope,
|
||||
ImportJobStatus, ImportOperationDraft, RegistryError,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -20,51 +24,167 @@ use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
|
||||
OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse,
|
||||
OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id,
|
||||
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, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
#[instrument(skip(self, upload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_openapi_import(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OpenApiImportPreviewPayload,
|
||||
upload: OpenApiUpload,
|
||||
) -> Result<OpenApiImportPreviewResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
let preview = crank_import::rest::preview_document(&payload.document)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
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 preview_payload = serde_json::to_value(&preview)
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.create_import_job(CreateImportJobRequest {
|
||||
id: &job_id,
|
||||
workspace_id,
|
||||
kind: ImportJobKind::OpenApi,
|
||||
source_format: &preview.source.format,
|
||||
source_version: preview.source.version.as_deref(),
|
||||
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?;
|
||||
.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,
|
||||
preview: parsed.preview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,9 +194,24 @@ impl AdminService {
|
||||
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?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
|
||||
return Err(ApiError::validation(
|
||||
@@ -101,13 +236,6 @@ impl AdminService {
|
||||
return Err(ApiError::validation("import job kind is not openapi"));
|
||||
}
|
||||
|
||||
let stored_preview = job
|
||||
.preview_payload
|
||||
.get("preview")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| job.preview_payload.clone());
|
||||
let preview: crank_import::rest::ImportPreview = serde_json::from_value(stored_preview)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let selected = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
@@ -118,9 +246,181 @@ impl AdminService {
|
||||
"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 &preview.groups {
|
||||
for group in &parsed.preview.groups {
|
||||
for operation in &group.operations {
|
||||
candidates.insert(operation.key.clone(), operation);
|
||||
}
|
||||
@@ -131,7 +431,7 @@ impl AdminService {
|
||||
|
||||
for operation_key in selected {
|
||||
let Some(candidate) = candidates.get(&operation_key) else {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
skipped.push(crank_registry::SkippedImportOperation {
|
||||
operation_key,
|
||||
name: String::new(),
|
||||
reason: "operation was not found in import preview".to_owned(),
|
||||
@@ -171,13 +471,6 @@ impl AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
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 applied = self
|
||||
.registry
|
||||
.apply_import_job(ApplyImportJobRequest {
|
||||
@@ -186,24 +479,52 @@ impl AdminService {
|
||||
application_key: &application_key,
|
||||
conflict_mode,
|
||||
operations: &operations,
|
||||
pre_skipped: &skipped,
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let created = applied
|
||||
.created
|
||||
.iter()
|
||||
.map(|operation| OpenApiImportCreatedOperation {
|
||||
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 {
|
||||
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!(
|
||||
@@ -212,39 +533,288 @@ impl AdminService {
|
||||
),
|
||||
operation_key: Some(operation.operation_key.clone()),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for operation in applied.skipped {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: operation.operation_key.clone(),
|
||||
name: operation.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: operation.reason,
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
operation.name
|
||||
),
|
||||
operation_key: Some(operation.operation_key),
|
||||
});
|
||||
}
|
||||
info!(
|
||||
name: "admin.openapi_import.completed",
|
||||
created = created.len(),
|
||||
skipped = skipped.len(),
|
||||
"openapi import created drafts"
|
||||
);
|
||||
|
||||
Ok(OpenApiImportCreateResponse {
|
||||
created,
|
||||
skipped,
|
||||
findings,
|
||||
})
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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,28 +1,52 @@
|
||||
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, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
|
||||
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord,
|
||||
InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery,
|
||||
UsageRollupRecord,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
|
||||
usage_window,
|
||||
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<u64, ApiError> {
|
||||
) -> Result<InvocationRetentionOutcome, ApiError> {
|
||||
self.registry
|
||||
.delete_invocation_logs_before(cutoff)
|
||||
.await
|
||||
@@ -34,25 +58,45 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
query: LogsQuery,
|
||||
) -> Result<Vec<InvocationLogRecord>, ApiError> {
|
||||
) -> 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, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
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);
|
||||
|
||||
self.registry
|
||||
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),
|
||||
limit: query.limit.unwrap_or(100),
|
||||
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
|
||||
.map_err(ApiError::from)
|
||||
.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))]
|
||||
@@ -72,6 +116,69 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -92,7 +199,7 @@ impl AdminService {
|
||||
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(record);
|
||||
normalized.push(safe_approval_record(record));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +224,114 @@ impl AdminService {
|
||||
)
|
||||
})?;
|
||||
|
||||
self.normalize_approval_record(record).await
|
||||
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(
|
||||
@@ -149,13 +363,14 @@ impl AdminService {
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageOverviewResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -168,16 +383,104 @@ impl AdminService {
|
||||
.registry
|
||||
.list_usage_by_operation(usage_query.clone())
|
||||
.await?;
|
||||
let agents = self.registry.list_usage_by_agent(usage_query).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,
|
||||
@@ -186,8 +489,8 @@ impl AdminService {
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageRollupRecord, ApiError> {
|
||||
self.get_operation(workspace_id, operation_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
let (period, created_after, created_before, bucket) =
|
||||
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
||||
|
||||
self.registry
|
||||
.get_usage_for_operation(
|
||||
@@ -196,6 +499,7 @@ impl AdminService {
|
||||
period,
|
||||
source: query.source,
|
||||
created_after: &created_after,
|
||||
created_before: &created_before,
|
||||
bucket,
|
||||
},
|
||||
operation_id,
|
||||
@@ -220,8 +524,8 @@ impl AdminService {
|
||||
query: UsageRequestQuery,
|
||||
) -> Result<UsageRollupRecord, ApiError> {
|
||||
self.get_agent(workspace_id, agent_id).await?;
|
||||
let (period, created_after, bucket) =
|
||||
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
|
||||
let (period, created_after, created_before, bucket) =
|
||||
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
|
||||
|
||||
self.registry
|
||||
.get_usage_for_agent(
|
||||
@@ -230,6 +534,7 @@ impl AdminService {
|
||||
period,
|
||||
source: query.source,
|
||||
created_after: &created_after,
|
||||
created_before: &created_before,
|
||||
bucket,
|
||||
},
|
||||
agent_id,
|
||||
@@ -243,3 +548,143 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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('"', "\"\""))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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,14 +1,17 @@
|
||||
use crank_core::{
|
||||
ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus,
|
||||
OperationId, OperationStatus, Samples, WorkspaceId,
|
||||
ConfigExport, ExecutionMode, ExecutionOrigin, ExportMode, InvocationLevel, InvocationSource,
|
||||
InvocationStatus, OperationAvailability, OperationId, OperationStatus, OperationVersionState,
|
||||
Samples, WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation,
|
||||
CreateVersionRequest, OperationStateExpectation, OperationVersionRecord, PublishRequest,
|
||||
RegistryOperation,
|
||||
};
|
||||
use crank_runtime::{
|
||||
RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
|
||||
ExecutionAuthorization, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
@@ -18,13 +21,33 @@ use crate::{
|
||||
AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload,
|
||||
OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView,
|
||||
PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef,
|
||||
agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary,
|
||||
format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc,
|
||||
tool_quality_mapping_set, tool_quality_schema_node, usage_map,
|
||||
agent_ref_map, default_usage_summary, enrich_operation_summary, format_timestamp,
|
||||
new_prefixed_id, now_string, today_start_utc, tool_quality_mapping_set,
|
||||
tool_quality_schema_node, usage_map,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub fn operation_state_etag(detail: &OperationDetailView) -> String {
|
||||
let material = format!(
|
||||
"operation-state-v2\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
detail.workspace_id,
|
||||
detail.id,
|
||||
detail.current_draft_version,
|
||||
detail.status,
|
||||
detail.latest_published_version
|
||||
);
|
||||
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
|
||||
pub fn operation_version_etag(record: &OperationVersionRecord) -> Result<String, ApiError> {
|
||||
let bytes = serde_json::to_vec(&record.snapshot)
|
||||
.map_err(|_| ApiError::internal("operation version serialization failed"))?;
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"operation-version-v2\0");
|
||||
digest.update(bytes);
|
||||
Ok(format!("\"{:x}\"", digest.finalize()))
|
||||
}
|
||||
pub async fn list_operations(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -84,6 +107,11 @@ impl AdminService {
|
||||
let refs = agent_ref_map(agent_refs)
|
||||
.remove(operation_id.as_str())
|
||||
.unwrap_or_default();
|
||||
let current_version = self
|
||||
.registry
|
||||
.get_operation_version(workspace_id, operation_id, summary.current_draft_version)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::internal("current operation version is unavailable"))?;
|
||||
|
||||
Ok(OperationDetailView {
|
||||
id: summary.id.as_str().to_owned(),
|
||||
@@ -94,6 +122,11 @@ impl AdminService {
|
||||
protocol: summary.protocol,
|
||||
security_level: summary.security_level,
|
||||
status: summary.status,
|
||||
availability: if summary.status == OperationStatus::Archived {
|
||||
OperationAvailability::Archived
|
||||
} else {
|
||||
OperationAvailability::Active
|
||||
},
|
||||
current_draft_version: summary.current_draft_version,
|
||||
latest_published_version: summary.latest_published_version,
|
||||
created_at: format_timestamp(summary.created_at),
|
||||
@@ -101,11 +134,15 @@ impl AdminService {
|
||||
published_at: summary.published_at.map(format_timestamp),
|
||||
draft_version_ref: VersionRef {
|
||||
version: summary.current_draft_version,
|
||||
status: summary.status,
|
||||
status: if current_version.status == OperationStatus::Published {
|
||||
OperationVersionState::Published
|
||||
} else {
|
||||
OperationVersionState::Draft
|
||||
},
|
||||
},
|
||||
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
|
||||
version,
|
||||
status: OperationStatus::Published,
|
||||
status: OperationVersionState::Published,
|
||||
}),
|
||||
agent_refs: refs,
|
||||
})
|
||||
@@ -155,6 +192,8 @@ impl AdminService {
|
||||
}
|
||||
|
||||
let snapshot = self.new_operation_snapshot(payload)?;
|
||||
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
|
||||
.await?;
|
||||
let operation_id = snapshot.id.clone();
|
||||
|
||||
self.registry
|
||||
@@ -240,6 +279,7 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: NewVersionPayload,
|
||||
expected_state: &OperationStateExpectation,
|
||||
) -> Result<CreatedOperationResponse, ApiError> {
|
||||
self.validate_operation_payload(&payload.operation)?;
|
||||
|
||||
@@ -254,7 +294,27 @@ impl AdminService {
|
||||
)
|
||||
})?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let version = summary.current_draft_version + 1;
|
||||
if summary.current_draft_version != expected_state.current_draft_version
|
||||
|| summary.status != expected_state.status
|
||||
|| summary.latest_published_version != expected_state.latest_published_version
|
||||
{
|
||||
return Err(crank_registry::RegistryError::OperationStaleVersion {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
expected: expected_state.current_draft_version,
|
||||
actual: summary.current_draft_version,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let version = summary
|
||||
.current_draft_version
|
||||
.checked_add(1)
|
||||
.filter(|value| i32::try_from(*value).is_ok())
|
||||
.ok_or_else(|| {
|
||||
ApiError::conflict_with_context(
|
||||
"operation revision limit reached",
|
||||
json!({ "error_code": "operation_revision_exhausted" }),
|
||||
)
|
||||
})?;
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
name: payload.operation.name,
|
||||
@@ -283,13 +343,19 @@ impl AdminService {
|
||||
published_at: None,
|
||||
};
|
||||
|
||||
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.create_version(CreateVersionRequest {
|
||||
workspace_id,
|
||||
snapshot: &snapshot,
|
||||
change_note: payload.change_note.as_deref(),
|
||||
created_by: None,
|
||||
})
|
||||
.create_version_cas(
|
||||
CreateVersionRequest {
|
||||
workspace_id,
|
||||
snapshot: &snapshot,
|
||||
change_note: payload.change_note.as_deref(),
|
||||
created_by: None,
|
||||
},
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.version_created",
|
||||
@@ -313,21 +379,20 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: UpdateOperationPayload,
|
||||
expected_state: &OperationStateExpectation,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
operation_id,
|
||||
self.get_operation(workspace_id, operation_id)
|
||||
.await?
|
||||
.current_draft_version,
|
||||
expected_state.current_draft_version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
let snapshot = RegistryOperation {
|
||||
id: operation_id.clone(),
|
||||
name: existing.snapshot.name,
|
||||
name: existing.snapshot.name.clone(),
|
||||
display_name: payload.display_name,
|
||||
category: payload.category,
|
||||
protocol: existing.snapshot.protocol,
|
||||
@@ -341,24 +406,39 @@ impl AdminService {
|
||||
output_mapping: payload.output_mapping,
|
||||
execution_config: payload.execution_config,
|
||||
tool_description: payload.tool_description,
|
||||
samples: existing.snapshot.samples,
|
||||
generated_draft: existing.snapshot.generated_draft,
|
||||
config_export: existing.snapshot.config_export,
|
||||
samples: existing.snapshot.samples.clone(),
|
||||
generated_draft: existing.snapshot.generated_draft.clone(),
|
||||
config_export: existing.snapshot.config_export.clone(),
|
||||
wizard_state: payload.wizard_state,
|
||||
created_at: existing.snapshot.created_at,
|
||||
updated_at,
|
||||
published_at: existing.snapshot.published_at,
|
||||
};
|
||||
|
||||
self.validate_registry_operation(&snapshot)?;
|
||||
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
|
||||
.await?;
|
||||
if existing.snapshot.portable_semantically_eq(&snapshot)
|
||||
&& existing.snapshot.wizard_state == snapshot.wizard_state
|
||||
{
|
||||
self.registry
|
||||
.verify_operation_state(workspace_id, operation_id, expected_state)
|
||||
.await?;
|
||||
return Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: existing.version,
|
||||
status: existing.status,
|
||||
updated_at: format_timestamp(existing.snapshot.updated_at),
|
||||
});
|
||||
}
|
||||
self.registry
|
||||
.update_operation_draft(workspace_id, &snapshot)
|
||||
.update_operation_draft_cas(workspace_id, &snapshot, expected_state)
|
||||
.await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: snapshot.version,
|
||||
version: snapshot.version.saturating_add(1),
|
||||
status: snapshot.status,
|
||||
updated_at: format_timestamp(updated_at),
|
||||
})
|
||||
@@ -370,16 +450,56 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
expected_state: &OperationStateExpectation,
|
||||
) -> Result<PublishResponse, ApiError> {
|
||||
let record = self
|
||||
.get_operation_version(workspace_id, operation_id, version)
|
||||
.await?;
|
||||
self.validate_registry_operation_in_workspace(workspace_id, &record.snapshot)
|
||||
.await?;
|
||||
let mut findings = crank_core::analyze_tool_identity_quality(
|
||||
&record.snapshot.name,
|
||||
&record.snapshot.tool_description,
|
||||
)
|
||||
.findings;
|
||||
findings.extend(
|
||||
crank_core::analyze_tool_schema_quality(
|
||||
"input_schema",
|
||||
&tool_quality_schema_node(&record.snapshot.input_schema),
|
||||
)
|
||||
.findings,
|
||||
);
|
||||
findings.extend(
|
||||
crank_core::analyze_tool_response_projection_quality(&tool_quality_mapping_set(
|
||||
&record.snapshot.output_mapping,
|
||||
))
|
||||
.findings,
|
||||
);
|
||||
let report = crank_core::ToolQualityReport::new(findings);
|
||||
if report.blocking {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation publish is blocked by quality findings",
|
||||
json!({
|
||||
"error_code": "operation_publish_blocked",
|
||||
"findings": report.findings
|
||||
}),
|
||||
));
|
||||
}
|
||||
let published_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id,
|
||||
operation_id,
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
})
|
||||
.publish_operation_cas(
|
||||
PublishRequest {
|
||||
workspace_id,
|
||||
operation_id,
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
},
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
let authoritative = self
|
||||
.get_operation_version(workspace_id, operation_id, version)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.operation.published",
|
||||
@@ -392,7 +512,11 @@ impl AdminService {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
published_version: version,
|
||||
published_at: format_timestamp(published_at),
|
||||
published_at: authoritative
|
||||
.snapshot
|
||||
.published_at
|
||||
.map(format_timestamp)
|
||||
.unwrap_or_else(|| format_timestamp(published_at)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -401,19 +525,21 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
expected_state: &OperationStateExpectation,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.archive_operation(workspace_id, operation_id, &updated_at)
|
||||
.archive_operation_cas(workspace_id, operation_id, &updated_at, expected_state)
|
||||
.await?;
|
||||
let authoritative = self.get_operation(workspace_id, operation_id).await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
operation_id: operation_id.as_str().to_owned(),
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
version: summary.current_draft_version,
|
||||
status: OperationStatus::Archived,
|
||||
updated_at: format_timestamp(updated_at),
|
||||
updated_at: authoritative.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -422,11 +548,12 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
expected_state: &OperationStateExpectation,
|
||||
) -> Result<OperationMutationResult, ApiError> {
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
let updated_at = now_string()?;
|
||||
self.registry
|
||||
.delete_operation(workspace_id, operation_id)
|
||||
.delete_operation_cas(workspace_id, operation_id, expected_state)
|
||||
.await?;
|
||||
|
||||
Ok(OperationMutationResult {
|
||||
@@ -444,71 +571,81 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
operation_id: &OperationId,
|
||||
payload: TestRunPayload,
|
||||
request_id: &str,
|
||||
correlation: &crank_core::CorrelationContext,
|
||||
) -> Result<TestRunResult, ApiError> {
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(request_id)
|
||||
let request_id = correlation.request_id().as_str();
|
||||
let trace_id = correlation.trace_id().as_str();
|
||||
let mut runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
|
||||
.with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun);
|
||||
if let Some(token) = payload.confirmation_token.as_deref() {
|
||||
runtime_request_context = runtime_request_context.with_confirmation_token(token);
|
||||
}
|
||||
let locale = payload
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|value| {
|
||||
value.eq_ignore_ascii_case("ru") || value.to_ascii_lowercase().starts_with("ru-")
|
||||
})
|
||||
.map_or(crank_core::ExecutionLocale::En, |_| {
|
||||
crank_core::ExecutionLocale::Ru
|
||||
});
|
||||
let record = self
|
||||
.get_operation_version(workspace_id, operation_id, payload.version)
|
||||
.await?;
|
||||
let summary = self.get_operation(workspace_id, operation_id).await?;
|
||||
if summary.status == OperationStatus::Archived
|
||||
|| record.status != OperationStatus::Draft
|
||||
|| summary.current_draft_version != payload.version
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"only the current saved Draft can be tested",
|
||||
json!({
|
||||
"operation_id": operation_id.as_str(),
|
||||
"current_version": summary.current_draft_version,
|
||||
"tested_version": payload.version,
|
||||
"error_code": "operation_invalid_transition"
|
||||
}),
|
||||
));
|
||||
}
|
||||
let runtime = RuntimeOperation::from(record.snapshot.clone());
|
||||
let mode = ExecutionMode::Unary;
|
||||
let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
|
||||
let preview_result = preview_span
|
||||
.in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
|
||||
let request_preview = match preview_result {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
crank_trace::StageOutcome::Error.record(&preview_span);
|
||||
crank_trace::ErrorCategory::Mapping.record(&preview_span);
|
||||
drop(preview_span);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
workspace_id,
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: "mapping preview failed".to_owned(),
|
||||
status_code: None,
|
||||
error_kind: Some("mapping".to_owned()),
|
||||
duration_ms: 0,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await;
|
||||
return Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
|
||||
error,
|
||||
))],
|
||||
});
|
||||
}
|
||||
};
|
||||
crank_trace::StageOutcome::Success.record(&preview_span);
|
||||
drop(preview_span);
|
||||
|
||||
let resolved_auth = self
|
||||
.resolve_operation_auth(workspace_id, &runtime.execution_config)
|
||||
.resolve_operation_auth(workspace_id, &runtime.execution_config, None)
|
||||
.await;
|
||||
let started_at = std::time::Instant::now();
|
||||
match match resolved_auth {
|
||||
let correlation = crank_core::CorrelationContext::new(
|
||||
runtime_request_context.request_id.clone(),
|
||||
runtime_request_context.trace_context.clone(),
|
||||
);
|
||||
let execution_result = match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
self.runtime
|
||||
.execute_request(
|
||||
RuntimeExecutionRequest::new(&runtime, &payload.input)
|
||||
.with_optional_auth(resolved_auth.as_ref())
|
||||
.with_context(&runtime_request_context),
|
||||
)
|
||||
.await
|
||||
match RuntimeExecutionRequest::try_new(
|
||||
workspace_id,
|
||||
ExecutionOrigin::AdminDraft,
|
||||
None,
|
||||
&runtime,
|
||||
&payload.input,
|
||||
ExecutionAuthorization::Authorized,
|
||||
resolved_auth.as_ref(),
|
||||
&runtime_request_context,
|
||||
std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(
|
||||
runtime.execution_config.timeout_ms.max(1),
|
||||
),
|
||||
) {
|
||||
Ok(request) => self.runtime.execute_outcome(request).await,
|
||||
Err(_) => Err(crank_core::ExecutionFailure::new(
|
||||
crank_core::ExecutionErrorCode::RuntimeInternal,
|
||||
correlation.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
} {
|
||||
Ok(response_preview) => {
|
||||
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
|
||||
};
|
||||
match execution_result {
|
||||
Ok(success) => {
|
||||
let response_preview = crank_core::sanitize_invocation_preview(&success.output);
|
||||
let request_preview = success.request_preview;
|
||||
let duration_ms =
|
||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
@@ -516,12 +653,17 @@ impl AdminService {
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
trace_id: Some(trace_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
message: "admin test run completed".to_owned(),
|
||||
status_code: None,
|
||||
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,
|
||||
request_preview: request_preview.clone(),
|
||||
response_preview: response_preview.clone(),
|
||||
@@ -530,12 +672,15 @@ impl AdminService {
|
||||
Ok(TestRunResult {
|
||||
ok: true,
|
||||
mode,
|
||||
tested_version: payload.version,
|
||||
request_id: request_id.to_owned(),
|
||||
trace_id: trace_id.to_owned(),
|
||||
request_preview,
|
||||
response_preview,
|
||||
errors: Vec::new(),
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
Err(failure) => {
|
||||
let duration_ms =
|
||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.record_invocation(InvocationRecordRequest {
|
||||
@@ -543,23 +688,33 @@ impl AdminService {
|
||||
agent_id: None,
|
||||
operation: &record.snapshot,
|
||||
request_id: Some(request_id),
|
||||
trace_id: Some(trace_id),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Error,
|
||||
status: InvocationStatus::Error,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
error_kind: Some(runtime_error_code(&error).to_owned()),
|
||||
message: "runtime execution failed".to_owned(),
|
||||
status_code: failure.upstream_status(),
|
||||
error_kind: Some(failure.error_code().as_str().to_owned()),
|
||||
execution_stage: Some(failure.stage()),
|
||||
execution_error_code: Some(failure.error_code()),
|
||||
retryability: Some(failure.retryability()),
|
||||
outcome_certainty: Some(failure.outcome_certainty()),
|
||||
duration_ms,
|
||||
request_preview: request_preview.clone(),
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
})
|
||||
.await;
|
||||
Ok(TestRunResult {
|
||||
ok: false,
|
||||
mode,
|
||||
request_preview,
|
||||
tested_version: payload.version,
|
||||
request_id: request_id.to_owned(),
|
||||
trace_id: trace_id.to_owned(),
|
||||
request_preview: Value::Null,
|
||||
response_preview: Value::Null,
|
||||
errors: vec![crate::error::runtime_test_failure(&error)],
|
||||
errors: vec![crate::error::execution_test_failure_localized(
|
||||
&failure, locale,
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ use tracing::{info, instrument};
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id,
|
||||
AdminAuditContext, AdminService, AuthProfilePayload, CredentialAuditRecord,
|
||||
RotateSecretPayload, SecretPayload, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -57,15 +58,46 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
|
||||
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
|
||||
pub async fn create_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
created_by: Option<&UserId>,
|
||||
payload: SecretPayload,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<Secret, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
validate_secret_payload(&payload)?;
|
||||
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: "pending",
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = validate_secret_payload(&payload) {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: "pending",
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let secret = Secret {
|
||||
@@ -82,89 +114,322 @@ impl AdminService {
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
.map_err(|error| ApiError::internal(error.to_string()));
|
||||
let ciphertext = match ciphertext {
|
||||
Ok(ciphertext) => ciphertext,
|
||||
Err(error) => {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret.id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &secret,
|
||||
ciphertext: &ciphertext,
|
||||
key_version: self.secret_crypto.key_version(),
|
||||
master_key_epoch: self.secret_crypto.master_key_epoch(),
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
let error = ApiError::from(error);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret.id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
info!(
|
||||
name: "admin.secret.created",
|
||||
secret_id = %secret.id.as_str(),
|
||||
"secret created"
|
||||
);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.created",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret.id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "success",
|
||||
reason: "credential_created",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
pub async fn rotate_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
created_by: Option<&UserId>,
|
||||
payload: RotateSecretPayload,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<Secret, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if payload.value.is_null() {
|
||||
return Err(ApiError::validation("secret value must not be null"));
|
||||
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotate_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
let existing = match self.get_secret(workspace_id, secret_id).await {
|
||||
Ok(secret) => secret,
|
||||
Err(error) => {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotate_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = validate_secret_value(existing.kind, &payload.value) {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotate_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
self.registry
|
||||
.map_err(|error| ApiError::internal(error.to_string()));
|
||||
let ciphertext = match ciphertext {
|
||||
Ok(ciphertext) => ciphertext,
|
||||
Err(error) => {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotate_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let version = match self
|
||||
.registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id,
|
||||
secret_id,
|
||||
ciphertext: &ciphertext,
|
||||
key_version: self.secret_crypto.key_version(),
|
||||
master_key_epoch: self.secret_crypto.master_key_epoch(),
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(version) => version,
|
||||
Err(error) => {
|
||||
let error = ApiError::from(error);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotate_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
info!(
|
||||
name: "admin.secret.rotated",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret rotated"
|
||||
);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.rotated",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "success",
|
||||
reason: "credential_rotated",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
self.get_secret(workspace_id, secret_id).await
|
||||
Ok(Secret {
|
||||
current_version: version.secret_version.version,
|
||||
updated_at: now,
|
||||
..existing
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
|
||||
pub async fn delete_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<(), ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
if let Some(profile) = self
|
||||
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.delete_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
let referencing_profiles = match self
|
||||
.registry
|
||||
.list_auth_profiles_referencing_secret(workspace_id, secret_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.await
|
||||
{
|
||||
Ok(profiles) => profiles,
|
||||
Err(error) => {
|
||||
let error = ApiError::from(error);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.delete_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(profile) = referencing_profiles.into_iter().next() {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.delete_denied",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "denied",
|
||||
reason: "secret_referenced_by_auth_profile",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
auth_profile_id: profile.id.as_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
self.registry.delete_secret(workspace_id, secret_id).await?;
|
||||
if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await {
|
||||
let error = ApiError::from(error);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.delete_failed",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
info!(
|
||||
name: "admin.secret.deleted",
|
||||
secret_id = %secret_id.as_str(),
|
||||
"secret deleted"
|
||||
);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.secret.deleted",
|
||||
target_kind: crank_core::AuditTargetKind::Secret,
|
||||
workspace_id,
|
||||
target_id: secret_id.as_str(),
|
||||
credential_type: "secret",
|
||||
outcome: "success",
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -185,39 +450,118 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
|
||||
#[instrument(skip(self, payload, audit_context), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
|
||||
pub async fn create_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: AuthProfilePayload,
|
||||
audit_context: Option<&AdminAuditContext>,
|
||||
) -> Result<AuthProfile, ApiError> {
|
||||
validate_auth_profile_kind(payload.kind, &payload.config)?;
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
self.validate_auth_profile_secret_ids(workspace_id, &payload.config)
|
||||
.await?;
|
||||
if let Err(error) = validate_auth_profile_payload(&payload) {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.auth_profile.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::AuthProfile,
|
||||
workspace_id,
|
||||
target_id: "pending",
|
||||
credential_type: "auth_profile",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.auth_profile.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::AuthProfile,
|
||||
workspace_id,
|
||||
target_id: "pending",
|
||||
credential_type: "auth_profile",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = self
|
||||
.validate_auth_profile_secret_ids(workspace_id, &payload.config)
|
||||
.await
|
||||
{
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.auth_profile.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::AuthProfile,
|
||||
workspace_id,
|
||||
target_id: "pending",
|
||||
credential_type: "auth_profile",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let profile = AuthProfile {
|
||||
id: AuthProfileId::new(new_prefixed_id("auth")),
|
||||
workspace_id: workspace_id.clone(),
|
||||
name: payload.name,
|
||||
name: payload.name.trim().to_owned(),
|
||||
kind: payload.kind,
|
||||
config: payload.config,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.registry
|
||||
if let Err(error) = self
|
||||
.registry
|
||||
.save_auth_profile(SaveAuthProfileRequest {
|
||||
workspace_id,
|
||||
profile: &profile,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
let error = ApiError::from(error);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.auth_profile.create_failed",
|
||||
target_kind: crank_core::AuditTargetKind::AuthProfile,
|
||||
workspace_id,
|
||||
target_id: profile.id.as_str(),
|
||||
credential_type: "auth_profile",
|
||||
outcome: "failure",
|
||||
reason: error.code(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
info!(
|
||||
name: "admin.auth_profile.created",
|
||||
auth_profile_id = %profile.id.as_str(),
|
||||
"auth profile created"
|
||||
);
|
||||
self.record_credential_audit(
|
||||
audit_context,
|
||||
CredentialAuditRecord {
|
||||
action: "credential.auth_profile.created",
|
||||
target_kind: crank_core::AuditTargetKind::AuthProfile,
|
||||
workspace_id,
|
||||
target_id: profile.id.as_str(),
|
||||
credential_type: "auth_profile",
|
||||
outcome: "success",
|
||||
reason: "credential_created",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
@@ -228,13 +572,45 @@ impl AdminService {
|
||||
config: &AuthConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
for secret_id in config.secret_ids() {
|
||||
self.get_secret(workspace_id, secret_id).await?;
|
||||
let secret = self.get_secret(workspace_id, secret_id).await?;
|
||||
if secret.status != SecretStatus::Active {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_auth_profile_payload(payload: &AuthProfilePayload) -> Result<(), ApiError> {
|
||||
const MAX_PROFILE_NAME_CHARS: usize = 128;
|
||||
|
||||
let profile_name = payload.name.trim();
|
||||
if profile_name.is_empty() || profile_name.chars().count() > MAX_PROFILE_NAME_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"auth profile name must contain 1 to 128 characters",
|
||||
));
|
||||
}
|
||||
if profile_name.chars().any(char::is_control) {
|
||||
return Err(ApiError::validation(
|
||||
"auth profile name contains unsupported characters",
|
||||
));
|
||||
}
|
||||
|
||||
validate_auth_profile_kind(payload.kind, &payload.config)?;
|
||||
match &payload.config {
|
||||
AuthConfig::Bearer(config) => validate_auth_header_name(&config.header_name)?,
|
||||
AuthConfig::Basic(_) => {}
|
||||
AuthConfig::ApiKeyHeader(config) => validate_auth_header_name(&config.header_name)?,
|
||||
AuthConfig::ApiKeyQuery(config) => validate_auth_query_name(&config.param_name)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
|
||||
let is_match = matches!(
|
||||
(kind, config),
|
||||
@@ -252,13 +628,145 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
|
||||
}
|
||||
|
||||
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(ApiError::validation("secret name must not be empty"));
|
||||
const MAX_SECRET_NAME_CHARS: usize = 128;
|
||||
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_SECRET_NAME_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"secret name must contain 1 to 128 characters",
|
||||
));
|
||||
}
|
||||
if name.chars().any(char::is_control) {
|
||||
return Err(ApiError::validation(
|
||||
"secret name contains unsupported characters",
|
||||
));
|
||||
}
|
||||
|
||||
if payload.value.is_null() {
|
||||
validate_secret_value(payload.kind, &payload.value)
|
||||
}
|
||||
|
||||
fn validate_secret_value(
|
||||
kind: crank_core::SecretKind,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<(), ApiError> {
|
||||
const MAX_SECRET_VALUE_BYTES: usize = 65_536;
|
||||
const MAX_SECRET_STRING_BYTES: usize = 16_384;
|
||||
|
||||
if value.is_null() {
|
||||
return Err(ApiError::validation("secret value must not be null"));
|
||||
}
|
||||
|
||||
let encoded = serde_json::to_vec(value)
|
||||
.map_err(|_| ApiError::validation("secret value must be valid JSON"))?;
|
||||
if encoded.len() > MAX_SECRET_VALUE_BYTES {
|
||||
return Err(ApiError::validation("secret value is too large"));
|
||||
}
|
||||
|
||||
match kind {
|
||||
crank_core::SecretKind::Token | crank_core::SecretKind::Header => {
|
||||
if value.is_string() {
|
||||
validate_non_empty_secret_string(value, "secret value", MAX_SECRET_STRING_BYTES)?;
|
||||
} else {
|
||||
let object = value.as_object().ok_or_else(|| {
|
||||
ApiError::validation(
|
||||
"token/header secret must be a string or a single token/value field",
|
||||
)
|
||||
})?;
|
||||
let field = object
|
||||
.get("token")
|
||||
.or_else(|| object.get("value"))
|
||||
.filter(|_| object.len() == 1)
|
||||
.ok_or_else(|| {
|
||||
ApiError::validation("token/header secret must contain only token or value")
|
||||
})?;
|
||||
validate_non_empty_secret_string(field, "secret value", MAX_SECRET_STRING_BYTES)?;
|
||||
}
|
||||
}
|
||||
crank_core::SecretKind::UsernamePassword => {
|
||||
let object = value.as_object().ok_or_else(|| {
|
||||
ApiError::validation("username_password secret must contain username and password")
|
||||
})?;
|
||||
if object.len() != 2
|
||||
|| !object.contains_key("username")
|
||||
|| !object.contains_key("password")
|
||||
{
|
||||
return Err(ApiError::validation(
|
||||
"username_password secret must contain only username and password",
|
||||
));
|
||||
}
|
||||
validate_non_empty_secret_string(
|
||||
&object["username"],
|
||||
"secret username",
|
||||
MAX_SECRET_STRING_BYTES,
|
||||
)?;
|
||||
validate_non_empty_secret_string(
|
||||
&object["password"],
|
||||
"secret password",
|
||||
MAX_SECRET_STRING_BYTES,
|
||||
)?;
|
||||
}
|
||||
crank_core::SecretKind::Generic => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_non_empty_secret_string(
|
||||
value: &serde_json::Value,
|
||||
field: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<(), ApiError> {
|
||||
let value = value
|
||||
.as_str()
|
||||
.ok_or_else(|| ApiError::validation(format!("{field} must be a string")))?;
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::validation(format!("{field} must not be empty")));
|
||||
}
|
||||
if value.len() > max_bytes {
|
||||
return Err(ApiError::validation(format!("{field} is too large")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_auth_header_name(value: &str) -> Result<(), ApiError> {
|
||||
const MAX_HEADER_NAME_BYTES: usize = 128;
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_HEADER_NAME_BYTES
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
byte,
|
||||
b'!' | b'#'
|
||||
| b'$'
|
||||
| b'%'
|
||||
| b'&'
|
||||
| b'\''
|
||||
| b'*'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~'
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ApiError::validation("auth header name is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_auth_query_name(value: &str) -> Result<(), ApiError> {
|
||||
const MAX_QUERY_NAME_BYTES: usize = 128;
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_QUERY_NAME_BYTES
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']')
|
||||
})
|
||||
{
|
||||
return Err(ApiError::validation("auth query parameter name is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ impl AdminService {
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id: Some(workspace_id.as_str().to_owned()),
|
||||
csrf_token: self.rotate_session_csrf_token(session_id).await?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::service::AdminService;
|
||||
use crank_runtime::RequestRateLimiter;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
pub api_rate_limiter: RequestRateLimiter,
|
||||
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
|
||||
/// Immediate peer IPs allowed to supply `X-Real-IP` / `X-Forwarded-For`.
|
||||
///
|
||||
/// Only enable when the service sits behind a trusted reverse proxy that
|
||||
/// overwrites these headers (e.g. the bundled nginx). When disabled the
|
||||
/// real TCP peer address is used, which a client cannot spoof.
|
||||
pub trust_forwarded_headers: bool,
|
||||
/// Only configure reverse proxies that overwrite these headers. When the
|
||||
/// peer is absent or not listed the real TCP peer address is used.
|
||||
pub trusted_proxy_ips: Vec<IpAddr>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
os::unix::fs::PermissionsExt,
|
||||
process::{Command, Stdio},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
const REGISTERED_MASTER_KEY: &str = "registered-master-key-CANARY_SECRET_VALUE-00000000";
|
||||
const WRONG_MASTER_KEY: &str = "wrong-master-key-CANARY_SECRET_VALUE-0000000000000";
|
||||
static NEXT_STORAGE_ROOT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn run_with(entries: &[(&str, &str)]) -> String {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_admin-api"));
|
||||
for field in crank_config::field_registry() {
|
||||
command.env_remove(field.env_name);
|
||||
}
|
||||
command.envs([
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
]);
|
||||
for (name, value) in entries {
|
||||
command.env(name, value);
|
||||
}
|
||||
let output = command.output().expect("admin binary executes");
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
|
||||
assert!(!stderr.contains("CANARY_SECRET_VALUE"));
|
||||
assert!(!stderr.contains("connection refused"));
|
||||
assert!(stderr.len() <= 65_536);
|
||||
stderr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_config_fails_before_database_or_listener_side_effects() {
|
||||
for (entries, code) in [
|
||||
(
|
||||
vec![("CRANK_CONFIG_CANARY_UNKNOWN", "CANARY_SECRET_VALUE")],
|
||||
"config.unknown_field",
|
||||
),
|
||||
(vec![("POSTGRES_PORT", "bad")], "config.invalid_type"),
|
||||
(
|
||||
vec![
|
||||
("CRANK_DATABASE_URL", "postgres://db/crank"),
|
||||
("POSTGRES_HOST", "other"),
|
||||
],
|
||||
"config.conflict",
|
||||
),
|
||||
(vec![("CRANK_LOG_LEVEL", "[")], "config.invalid_type"),
|
||||
] {
|
||||
let stderr = run_with(&entries);
|
||||
assert!(stderr.contains(code), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_driver_failures_are_normalized_and_redacted() {
|
||||
let stderr = run_with(&[(
|
||||
"CRANK_DATABASE_URL",
|
||||
"postgres://CANARY_SECRET_VALUE:CANARY_SECRET_VALUE@127.0.0.1:1/crank",
|
||||
)]);
|
||||
assert!(stderr.contains("startup_failed"), "{stderr}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_database_startup_is_read_only() {
|
||||
let database_url = crank_test_support::postgres_schema_url("admin_startup_read_only").await;
|
||||
let stderr = run_with(&[("CRANK_DATABASE_URL", &database_url)]);
|
||||
assert!(stderr.contains("schema_missing"), "{stderr}");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_admin_startup_runs_immediate_import_maintenance() {
|
||||
let unique = NEXT_STORAGE_ROOT.fetch_add(1, Ordering::Relaxed);
|
||||
let epoch = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url(&format!("startup_maint_{epoch}_{unique}")).await;
|
||||
let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate"))
|
||||
.arg("apply")
|
||||
.env("CRANK_DATABASE_URL", &database_url)
|
||||
.output()
|
||||
.expect("migration command executes");
|
||||
assert!(
|
||||
applied.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&applied.stderr)
|
||||
);
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, Option<i64>>("select max(version) from __crank_migrations")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(13),
|
||||
"migration binary must install the current canonical ledger",
|
||||
);
|
||||
sqlx::query(
|
||||
"insert into import_jobs (
|
||||
id, workspace_id, kind, source_format, source_version, status,
|
||||
preview_payload, created_operation_ids, error_text, created_at, expires_at, finished_at
|
||||
) values (
|
||||
'imp_startup_expired', 'ws_default', 'openapi', 'openapi', null, 'pending',
|
||||
'{\"source\": {\"source_id\": 1}}'::jsonb, '[]'::jsonb, null,
|
||||
now() - interval '2 hours', now() - interval '1 hour', null
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let storage_root = std::env::temp_dir().join(format!(
|
||||
"crank-admin-startup-maintenance-{}-{epoch}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&storage_root).unwrap();
|
||||
fs::set_permissions(&storage_root, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let bind_addr = listener.local_addr().unwrap().to_string();
|
||||
drop(listener);
|
||||
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_admin-api"));
|
||||
for field in crank_config::field_registry() {
|
||||
command.env_remove(field.env_name);
|
||||
}
|
||||
let mut child = command
|
||||
.envs([
|
||||
("CRANK_DATABASE_URL", database_url.as_str()),
|
||||
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
("CRANK_ADMIN_BIND", bind_addr.as_str()),
|
||||
("CRANK_STORAGE_ROOT", storage_root.to_str().unwrap()),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("admin binary starts");
|
||||
|
||||
let mut cleaned = false;
|
||||
for _ in 0..200 {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut stream) = child.stderr.take() {
|
||||
stream.read_to_string(&mut stderr).unwrap();
|
||||
}
|
||||
panic!("admin binary exited before startup maintenance: {status}: {stderr}");
|
||||
}
|
||||
let remaining: i64 =
|
||||
sqlx::query_scalar("select count(*) from import_jobs where id = 'imp_startup_expired'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if remaining == 0 {
|
||||
cleaned = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
child.kill().expect("admin binary can be stopped");
|
||||
child.wait().expect("admin binary can be reaped");
|
||||
fs::remove_dir_all(&storage_root).unwrap();
|
||||
assert!(cleaned, "real startup did not clean the expired import job");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn master_key_mismatch_blocks_startup_with_safe_diagnostic() {
|
||||
let database_url = crank_test_support::postgres_schema_url("admin_master_key_mismatch").await;
|
||||
let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate"))
|
||||
.arg("apply")
|
||||
.env("CRANK_DATABASE_URL", &database_url)
|
||||
.output()
|
||||
.expect("migration command executes");
|
||||
assert!(
|
||||
applied.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&applied.stderr)
|
||||
);
|
||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
||||
let crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap();
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: crypto.master_key_fingerprint(),
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &OffsetDateTime::parse("2026-08-21T00:00:00Z", &Rfc3339).unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stderr = run_with(&[
|
||||
("CRANK_DATABASE_URL", &database_url),
|
||||
("CRANK_MASTER_KEY", WRONG_MASTER_KEY),
|
||||
]);
|
||||
assert!(stderr.contains("master_key_identity_mismatch"), "{stderr}");
|
||||
assert!(!stderr.contains("registered-master-key"));
|
||||
assert!(!stderr.contains("wrong-master-key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populated_database_without_identity_rejects_wrong_first_key() {
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("admin_master_key_first_registration_wrong").await;
|
||||
let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate"))
|
||||
.arg("apply")
|
||||
.env("CRANK_DATABASE_URL", &database_url)
|
||||
.output()
|
||||
.expect("migration command executes");
|
||||
assert!(
|
||||
applied.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&applied.stderr)
|
||||
);
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
let old_crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap();
|
||||
let ciphertext = old_crypto
|
||||
.encrypt(&serde_json::json!({"token": "CANARY_SECRET_VALUE"}))
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into secrets (
|
||||
id, workspace_id, name, kind, status, current_version, created_at, updated_at
|
||||
) values (
|
||||
'legacy_secret', 'ws_default', 'legacy secret', 'token', 'active', 1, now(), now()
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into secret_versions (
|
||||
secret_id, version, ciphertext, key_version, master_key_epoch, created_at, created_by
|
||||
) values (
|
||||
'legacy_secret', 1, $1, $2, 1, now(), null
|
||||
)",
|
||||
)
|
||||
.bind(ciphertext)
|
||||
.bind(old_crypto.key_version())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stderr = run_with(&[
|
||||
("CRANK_DATABASE_URL", &database_url),
|
||||
("CRANK_MASTER_KEY", WRONG_MASTER_KEY),
|
||||
]);
|
||||
assert!(stderr.contains("startup_failed"), "{stderr}");
|
||||
assert!(!stderr.contains("CANARY_SECRET_VALUE"));
|
||||
let identities: i64 = sqlx::query_scalar("select count(*) from master_key_identities")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(identities, 0);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
mod integration {
|
||||
mod agent_catalog;
|
||||
mod auth_rate_limit;
|
||||
mod common;
|
||||
mod community_access_usage;
|
||||
mod credential_lifecycle;
|
||||
mod logs_usage;
|
||||
mod onboarding;
|
||||
mod openapi_import;
|
||||
mod openapi_source;
|
||||
mod operation_lifecycle;
|
||||
mod operations_agents;
|
||||
mod request_context;
|
||||
mod secrets_import_auth;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn binding_requires_exact_published_operation_version() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_catalog_binding_requires_published");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let draft_operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"draft_not_bindable_tool",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "strict-catalog-agent",
|
||||
"display_name": "Strict Catalog Agent",
|
||||
"description": "Rejects draft bindings",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": draft_operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "draft_not_bindable_tool",
|
||||
"tool_title": "Draft should not bind",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
body["error"]["code"], "agent_binding_not_published",
|
||||
"binding Draft Operation must fail before publish and not be silently filtered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn stale_agent_revision_rejects_mutation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_catalog_stale_revision");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"stale_agent_tool",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "stale-agent",
|
||||
"display_name": "Stale Agent",
|
||||
"description": "Stale revision test",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "stale_agent_tool",
|
||||
"tool_title": "Stale Agent Tool",
|
||||
"enabled": true
|
||||
}]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.json(&json!([]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::PRECONDITION_REQUIRED,
|
||||
"Agent mutations after read must require current revision precondition"
|
||||
);
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(body["error"]["code"], "agent_precondition_required");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn unpublishes_and_archives_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_statuses");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_create_lead_agent_status",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, &operation_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let created = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.json(&json!({
|
||||
"slug": "sales-routing",
|
||||
"display_name": "Sales Routing",
|
||||
"description": "Routing agent",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/bindings"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!([
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"operation_version": 1,
|
||||
"tool_name": "crm_create_lead_agent_status",
|
||||
"tool_title": "Create Lead",
|
||||
"tool_description_override": null,
|
||||
"enabled": true
|
||||
}
|
||||
]))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/publish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let unpublished = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/unpublish"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unpublished["agent_id"], agent_id);
|
||||
|
||||
let draft_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(draft_detail["status"], "draft");
|
||||
assert_eq!(draft_detail["latest_published_version"], 1);
|
||||
|
||||
let archived = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/archive"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
agent_etag(&client, &base_url, &agent_id).await,
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived["agent_id"], agent_id);
|
||||
|
||||
let archived_detail = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(archived_detail["status"], "archived");
|
||||
}
|
||||
@@ -11,17 +11,22 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_registry::{
|
||||
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
|
||||
MigrationAuthority, PostgresRegistry,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use admin_api::{
|
||||
@@ -36,7 +41,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
@@ -92,6 +97,48 @@ impl IdentityProvider for RejectingIdentityProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async fn empty_registry(name: &str) -> PostgresRegistry {
|
||||
let database_url = crank_test_support::postgres_schema_url(name).await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
||||
let secret_crypto = test_secret_crypto();
|
||||
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: &time::OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn bootstrap_token_hash(token: &str) -> String {
|
||||
let digest = Sha256::digest(format!("bootstrap:{token}").as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
}
|
||||
|
||||
async fn login_client_without_csrf(root_url: &str) -> reqwest::Client {
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
client
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_rapid_login_requests_with_429() {
|
||||
@@ -109,7 +156,7 @@ async fn rejects_rapid_login_requests_with_429() {
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
trusted_proxy_ips: Vec::new(),
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -195,3 +242,119 @@ async fn login_uses_identity_provider_when_configured() {
|
||||
};
|
||||
assert_eq!(error.to_string(), "invalid email or password");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn bootstrap_token_creates_first_admin_once_and_never_replays() {
|
||||
let registry = empty_registry("test_admin_api_bootstrap_token").await;
|
||||
let token = "bootstrap-token-story-1-12-local-operator-only";
|
||||
registry
|
||||
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
|
||||
id: "boot_test_story_1_12",
|
||||
token_hash: &bootstrap_token_hash(token),
|
||||
email: "first-owner@crank.local",
|
||||
display_name: "First Owner",
|
||||
expires_at: &(time::OffsetDateTime::now_utc() + time::Duration::minutes(15)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let storage_root = test_storage_root("bootstrap_token");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let status = assert_success_json(
|
||||
client
|
||||
.get(format!("{root_url}/api/auth/bootstrap/status"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status["bootstrap_required"], true);
|
||||
|
||||
let completed = assert_success_json(
|
||||
client
|
||||
.post(format!("{root_url}/api/auth/bootstrap/complete"))
|
||||
.json(&json!({
|
||||
"token": token,
|
||||
"password": "first-owner-password-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(completed["user"]["email"], "first-owner@crank.local");
|
||||
assert!(completed["csrf_token"].as_str().unwrap().len() >= 32);
|
||||
|
||||
let replay = client
|
||||
.post(format!("{root_url}/api/auth/bootstrap/complete"))
|
||||
.json(&json!({
|
||||
"token": token,
|
||||
"password": "second-password-should-not-work"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let status_after = assert_success_json(
|
||||
client
|
||||
.get(format!("{root_url}/api/auth/bootstrap/status"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status_after["bootstrap_required"], false);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn browser_mutations_require_csrf_and_reject_cross_origin_requests() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("csrf_and_origin");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let root_url = base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let client = login_client_without_csrf(&root_url).await;
|
||||
|
||||
let missing_csrf = client
|
||||
.patch(format!("{root_url}/api/auth/profile"))
|
||||
.json(&json!({
|
||||
"display_name": "Blocked Without CSRF",
|
||||
"email": TEST_AUTH_EMAIL
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_csrf.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let missing_body = missing_csrf.text().await.unwrap();
|
||||
assert!(!missing_body.contains(TEST_SESSION_SECRET));
|
||||
assert!(!missing_body.contains(TEST_AUTH_PASSWORD));
|
||||
|
||||
let cross_origin = client
|
||||
.get(format!("{root_url}/api/auth/profile"))
|
||||
.header("origin", "https://attacker.example")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cross_origin.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let cross_origin_body = cross_origin.text().await.unwrap();
|
||||
assert!(!cross_origin_body.contains(TEST_SESSION_SECRET));
|
||||
assert!(!cross_origin_body.contains(TEST_AUTH_PASSWORD));
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ use std::{
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
AuditSink, ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
|
||||
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
@@ -34,7 +34,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
pub(super) struct TestServer {
|
||||
base_url: String,
|
||||
@@ -104,7 +104,54 @@ pub(super) fn build_test_app(
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
),
|
||||
trust_forwarded_headers: false,
|
||||
trusted_proxy_ips: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_test_app_with_external_references(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
allowed_url_prefixes: Vec<String>,
|
||||
) -> Router {
|
||||
build_app(AppState {
|
||||
service: test_service_with_external_references_timeout(
|
||||
registry,
|
||||
storage_root,
|
||||
allowed_url_prefixes,
|
||||
// This HTTP fixture asserts classification of an immediate
|
||||
// upstream status. Leave headroom for CI scheduling jitter so the
|
||||
// unrelated chain-timeout branch cannot win first.
|
||||
10_000,
|
||||
),
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
),
|
||||
trusted_proxy_ips: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_test_app_with_audit_sink(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
audit_sink: Arc<dyn AuditSink>,
|
||||
) -> Router {
|
||||
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||
build_app(AppState {
|
||||
service: AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
runtime,
|
||||
)
|
||||
.with_outbound_http_policy(outbound_policy)
|
||||
.with_audit_sink(audit_sink)
|
||||
.build(),
|
||||
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
|
||||
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
|
||||
),
|
||||
trusted_proxy_ips: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,6 +174,47 @@ pub(super) fn test_service(
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn test_service_with_external_references(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
allowed_url_prefixes: Vec<String>,
|
||||
) -> AdminService {
|
||||
test_service_with_external_references_timeout(
|
||||
registry,
|
||||
storage_root,
|
||||
allowed_url_prefixes,
|
||||
2_000,
|
||||
)
|
||||
}
|
||||
|
||||
fn test_service_with_external_references_timeout(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: std::path::PathBuf,
|
||||
allowed_url_prefixes: Vec<String>,
|
||||
fetch_timeout_ms: u64,
|
||||
) -> AdminService {
|
||||
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||
AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
runtime,
|
||||
)
|
||||
.with_external_reference_import(&crank_config::ExternalReferenceSettings {
|
||||
allowed_url_prefixes,
|
||||
max_depth: 8,
|
||||
max_documents: 32,
|
||||
max_fetch_bytes: 64 * 1024,
|
||||
fetch_timeout_ms,
|
||||
max_expanded_nodes: 10_000,
|
||||
})
|
||||
.unwrap()
|
||||
.with_outbound_http_policy(outbound_policy)
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_upstream_server() -> String {
|
||||
let app = Router::new().route("/crm/leads", post(create_lead));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -169,12 +257,13 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap();
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
|
||||
let login_client = reqwest::Client::builder()
|
||||
.cookie_provider(cookie_jar.clone())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
let login = login_client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
@@ -184,9 +273,67 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let csrf_token = login["csrf_token"]
|
||||
.as_str()
|
||||
.expect("login response must include csrf_token");
|
||||
let mut default_headers = reqwest::header::HeaderMap::new();
|
||||
default_headers.insert(
|
||||
"x-csrf-token",
|
||||
reqwest::header::HeaderValue::from_str(csrf_token).unwrap(),
|
||||
);
|
||||
|
||||
client
|
||||
reqwest::Client::builder()
|
||||
.cookie_provider(cookie_jar)
|
||||
.default_headers(default_headers)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn operation_etag(
|
||||
client: &reqwest::Client,
|
||||
workspace_base_url: impl AsRef<str>,
|
||||
operation_id: &str,
|
||||
) -> String {
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{}/operations/{operation_id}",
|
||||
workspace_base_url.as_ref()
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::ETAG)
|
||||
.expect("operation detail must expose an ETag")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub(super) async fn agent_etag(
|
||||
client: &reqwest::Client,
|
||||
workspace_base_url: impl AsRef<str>,
|
||||
agent_id: &str,
|
||||
) -> String {
|
||||
let response = client
|
||||
.get(format!("{}/agents/{agent_id}", workspace_base_url.as_ref()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::ETAG)
|
||||
.expect("agent detail must expose an ETag")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub(super) async fn assert_success_json(response: reqwest::Response) -> Value {
|
||||
@@ -211,7 +358,21 @@ pub(super) async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
|
||||
pub(super) async fn test_registry() -> PostgresRegistry {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_admin_api").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
crank_registry::MigrationAuthority::apply(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
|
||||
let secret_crypto = test_secret_crypto();
|
||||
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: &time::OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
|
||||
let user_id = registry
|
||||
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
|
||||
@@ -230,14 +391,18 @@ pub(super) async fn test_registry() -> PostgresRegistry {
|
||||
}
|
||||
|
||||
pub(super) fn test_storage_root(name: &str) -> std::path::PathBuf {
|
||||
env::temp_dir().join(format!(
|
||||
let root = env::temp_dir().join(format!(
|
||||
"crank_admin_api_{name}_{}_{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
#[cfg(unix)]
|
||||
std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
pub(super) fn test_auth_settings() -> AuthSettings {
|
||||
|
||||
@@ -39,7 +39,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
@@ -478,6 +478,11 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&smoke_operation_id,
|
||||
smoke_operation.version,
|
||||
&crank_registry::OperationStateExpectation {
|
||||
current_draft_version: smoke_operation.version,
|
||||
status: crank_core::OperationStatus::Draft,
|
||||
latest_published_version: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -508,6 +513,7 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
enabled: true,
|
||||
}]
|
||||
.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -516,6 +522,7 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&smoke_agent_id,
|
||||
smoke_agent.version,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -573,17 +580,22 @@ async fn seeds_demo_assets_for_live_ui() {
|
||||
&default_workspace_id,
|
||||
admin_api::service::LogsQuery {
|
||||
level: None,
|
||||
status: None,
|
||||
search: None,
|
||||
source: None,
|
||||
operation_id: None,
|
||||
agent_id: None,
|
||||
outcome_group: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
period: None,
|
||||
cursor: None,
|
||||
limit: Some(20),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!logs.is_empty());
|
||||
assert!(!logs.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -646,9 +658,11 @@ async fn updates_profile_and_changes_password() {
|
||||
updated_profile["user"]["email"],
|
||||
"updated-owner@crank.local"
|
||||
);
|
||||
let updated_csrf_token = updated_profile["csrf_token"].as_str().unwrap();
|
||||
|
||||
let password_status = client
|
||||
.post(format!("{root_url}/api/auth/password"))
|
||||
.header("x-csrf-token", updated_csrf_token)
|
||||
.json(&json!({
|
||||
"current_password": TEST_AUTH_PASSWORD,
|
||||
"new_password": "updated-password-123"
|
||||
@@ -671,7 +685,7 @@ async fn updates_profile_and_changes_password() {
|
||||
.await
|
||||
.unwrap()
|
||||
.status();
|
||||
assert_eq!(current_session_status, reqwest::StatusCode::OK);
|
||||
assert_eq!(current_session_status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let relogin_client = reqwest::Client::builder()
|
||||
@@ -741,87 +755,6 @@ async fn rejects_multi_workspace_session_switching_in_community() {
|
||||
assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exposes_logs_and_usage_from_real_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_observability",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned();
|
||||
let log_detail = client
|
||||
.get(format!("{base_url}/logs/{log_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let usage = client
|
||||
.get(format!("{base_url}/usage?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_usage = client
|
||||
.get(format!(
|
||||
"{base_url}/usage/operations/{operation_id}?period=7d"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run");
|
||||
assert_eq!(logs["items"][0]["operation_name"], "crm_observability");
|
||||
assert_eq!(log_detail["log"]["status"], "ok");
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_total"], 1);
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1);
|
||||
assert_eq!(
|
||||
usage["operations"][0]["operation_name"],
|
||||
"crm_observability"
|
||||
);
|
||||
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn preserves_request_id_for_test_run_invocations() {
|
||||
|
||||
@@ -0,0 +1,994 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{Json, Router, http::HeaderMap, routing::post};
|
||||
use crank_core::{AuditError, AuditEvent, AuditSink, PlatformApiKeyKind, SecretKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingAuditSink {
|
||||
events: Arc<Mutex<Vec<AuditEvent>>>,
|
||||
}
|
||||
|
||||
impl RecordingAuditSink {
|
||||
fn events(&self) -> Vec<AuditEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AuditSink for RecordingAuditSink {
|
||||
async fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
|
||||
self.events.lock().unwrap().push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn machine_key_raw_value_is_create_only_and_duplicate_name_is_typed_conflict() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_keys");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let agent_id = create_agent(&client, &base_url, "credential-lifecycle-agent").await;
|
||||
|
||||
let created = post_json(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
json!({"name": "primary-mcp-key", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "write"]}),
|
||||
)
|
||||
.await;
|
||||
let raw_key = created["secret"]
|
||||
.as_str()
|
||||
.expect("create response must disclose the raw key once")
|
||||
.to_owned();
|
||||
let random_part = raw_key
|
||||
.strip_prefix("crk_")
|
||||
.expect("MCP key must use the MCP bearer marker");
|
||||
assert_eq!(random_part.len(), 43, "32 raw bytes in unpadded base64url");
|
||||
assert!(
|
||||
random_part
|
||||
.bytes()
|
||||
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') })
|
||||
);
|
||||
assert_eq!(
|
||||
created["connection"]["endpoint"],
|
||||
"http://localhost:3000/mcp/v1/default/credential-lifecycle-agent"
|
||||
);
|
||||
assert!(
|
||||
created["connection"]["clients"]
|
||||
.as_array()
|
||||
.is_some_and(|clients| !clients.is_empty())
|
||||
);
|
||||
|
||||
let approval = post_json(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
json!({"name": "approval-without-mcp-config", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"]}),
|
||||
)
|
||||
.await;
|
||||
assert!(approval.get("connection").is_none());
|
||||
|
||||
let listed_text = client
|
||||
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!listed_text.contains(&raw_key));
|
||||
assert!(!listed_text.contains("secret_hash"));
|
||||
assert!(!listed_text.contains("\"secret\""));
|
||||
|
||||
let duplicate = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "primary-mcp-key",
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(duplicate.status(), reqwest::StatusCode::CONFLICT);
|
||||
let duplicate_body = duplicate.text().await.unwrap();
|
||||
assert!(!duplicate_body.contains(&raw_key));
|
||||
assert!(!duplicate_body.contains("secret_hash"));
|
||||
assert!(!duplicate_body.contains("duplicate key value"));
|
||||
let duplicate_json: Value = serde_json::from_str(&duplicate_body).unwrap();
|
||||
assert_eq!(
|
||||
duplicate_json["error"]["code"],
|
||||
"platform_api_key_name_conflict"
|
||||
);
|
||||
|
||||
for payload in [
|
||||
json!({"name": "n".repeat(129), "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"]}),
|
||||
json!({"name": "duplicate-scope", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "read"]}),
|
||||
json!({"name": "past-expiry", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"], "expires_at": "2020-01-01T00:00:00Z"}),
|
||||
] {
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
payload,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let canonical = post_json(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
json!({"name": "canonical-approval-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["HTTPS://Example.COM:443/", "http://EXAMPLE.com:80/"]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
canonical["api_key"]["api_key"]["allowed_origins"],
|
||||
json!(["https://example.com", "http://example.com"])
|
||||
);
|
||||
for (index, origin) in [
|
||||
"https://[::1",
|
||||
"https://example.test:bad/",
|
||||
"https://user:pass@example.test/",
|
||||
"https://example.test/path",
|
||||
"https://example.test/?token=origin-canary",
|
||||
"https://example.test/#fragment",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
json!({"name": format!("malformed-origin-{index}"), "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": [origin]}),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
|
||||
json!({"name": "duplicate-canonical-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["https://EXAMPLE.test:443/", "https://example.test/"]}),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn deleted_machine_key_name_can_be_reused_and_terminal_state_cannot_regress() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_key_reuse");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let agent_id = create_agent(&client, &base_url, "credential-key-reuse-agent").await;
|
||||
|
||||
let first = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "replaceable-key",
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let first_key_id = first["api_key"]["api_key"]["id"].as_str().unwrap();
|
||||
|
||||
let delete_response = client
|
||||
.delete(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let revoke_deleted = client
|
||||
.post(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}/revoke"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoke_deleted.status(), reqwest::StatusCode::CONFLICT);
|
||||
let revoke_body: Value = serde_json::from_str(&revoke_deleted.text().await.unwrap()).unwrap();
|
||||
assert_eq!(revoke_body["error"]["code"], "platform_api_key_not_active");
|
||||
|
||||
let second = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.json(&json!({
|
||||
"name": "replaceable-key",
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let second_key_id = second["api_key"]["api_key"]["id"].as_str().unwrap();
|
||||
assert_ne!(first_key_id, second_key_id);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn secret_plaintext_is_write_only_and_auth_profile_stores_reference() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_secrets");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let canary = "secret-canary-1-10-do-not-leak";
|
||||
|
||||
let created_response = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-bearer-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": canary }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let created_text = created_response.text().await.unwrap();
|
||||
assert!(!created_text.contains(canary));
|
||||
assert!(!created_text.contains("ciphertext"));
|
||||
let created: Value = serde_json::from_str(&created_text).unwrap();
|
||||
let secret_id = created["id"].as_str().unwrap();
|
||||
|
||||
let listed_text = client
|
||||
.get(format!("{base_url}/secrets"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!listed_text.contains(canary));
|
||||
assert!(!listed_text.contains("ciphertext"));
|
||||
|
||||
let fetched_text = client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!fetched_text.contains(canary));
|
||||
assert!(!fetched_text.contains("ciphertext"));
|
||||
|
||||
let auth_profile_text = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-bearer",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!auth_profile_text.contains(canary));
|
||||
assert!(!auth_profile_text.contains("ciphertext"));
|
||||
let auth_profile: Value = serde_json::from_str(&auth_profile_text).unwrap();
|
||||
assert_eq!(auth_profile["config"]["bearer"]["secret_id"], secret_id);
|
||||
|
||||
for (name, kind, value) in [
|
||||
("null-token", SecretKind::Token, Value::Null),
|
||||
(
|
||||
"wrong-token-shape",
|
||||
SecretKind::Token,
|
||||
json!({"token": "token", "extra": "field"}),
|
||||
),
|
||||
(
|
||||
"wrong-header-shape",
|
||||
SecretKind::Header,
|
||||
json!({"header_name": "X-Token"}),
|
||||
),
|
||||
(
|
||||
"wrong-username-password-shape",
|
||||
SecretKind::UsernamePassword,
|
||||
json!({"username": "user"}),
|
||||
),
|
||||
] {
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/secrets"),
|
||||
json!({"name": name, "kind": kind, "value": value}),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/secrets"),
|
||||
json!({"name": "oversized-secret", "kind": SecretKind::Generic, "value": "x".repeat(65_537)}),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
for value in [
|
||||
Value::Null,
|
||||
json!({"token": "token", "extra": "field"}),
|
||||
json!("x".repeat(65_537)),
|
||||
] {
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/secrets/{secret_id}/rotate"),
|
||||
json!({"value": value}),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let current = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(current["current_version"], 1);
|
||||
|
||||
for payload in [
|
||||
json!({"name": "", "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
|
||||
json!({"name": "n".repeat(129), "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
|
||||
json!({"name": "invalid-header-name", "kind": "bearer", "config": {"bearer": {"header_name": "Bad Header", "secret_id": secret_id}}}),
|
||||
json!({"name": "invalid-query-name", "kind": "api_key_query", "config": {"api_key_query": {"param_name": "bad name", "secret_id": secret_id}}}),
|
||||
] {
|
||||
assert_post_error(
|
||||
&client,
|
||||
format!("{base_url}/auth-profiles"),
|
||||
payload,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"validation_error",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn credential_mutations_emit_bounded_non_secret_audit_events() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_audit");
|
||||
let audit_sink = RecordingAuditSink::default();
|
||||
let base_url = spawn_admin_api(build_test_app_with_audit_sink(
|
||||
registry,
|
||||
storage_root,
|
||||
Arc::new(audit_sink.clone()),
|
||||
))
|
||||
.await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let request_id = "req_credential_audit_1";
|
||||
let traceparent = "00-11111111111111111111111111111111-2222222222222222-01";
|
||||
|
||||
let secret = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "audit-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "audit-secret-canary" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"value": { "token": "audit-rotated-canary" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth_profile = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "audit-profile",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
||||
|
||||
let delete_denied = client
|
||||
.delete(format!("{base_url}/secrets/{secret_id}"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_denied.status(), reqwest::StatusCode::CONFLICT);
|
||||
|
||||
let created_agent = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"slug": "credential-audit-agent",
|
||||
"display_name": "Credential Audit Agent",
|
||||
"description": "Agent for credential audit tests.",
|
||||
"instructions": {},
|
||||
"tool_selection_policy": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let agent_id = created_agent["agent_id"].as_str().unwrap();
|
||||
let created_key = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "audit-mcp-key",
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let raw_key = created_key["secret"].as_str().unwrap();
|
||||
let key_id = created_key["api_key"]["api_key"]["id"].as_str().unwrap();
|
||||
let duplicate_key = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "audit-mcp-key",
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(duplicate_key.status(), reqwest::StatusCode::CONFLICT);
|
||||
let validation_key = client
|
||||
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "n".repeat(129),
|
||||
"key_kind": PlatformApiKeyKind::McpClient,
|
||||
"scopes": ["read"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validation_key.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
let invalid_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.json(&json!({
|
||||
"name": "audit-invalid-profile",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": "secret_missing_for_audit"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid_profile.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
client
|
||||
.post(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
|
||||
))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let delete_response = client
|
||||
.delete(format!(
|
||||
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
|
||||
))
|
||||
.header("x-request-id", request_id)
|
||||
.header("traceparent", traceparent)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let listed_after_delete = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let deleted_metadata = listed_after_delete["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["api_key"]["id"] == key_id)
|
||||
.expect("delete must preserve key metadata for provenance");
|
||||
assert_eq!(deleted_metadata["api_key"]["status"], "deleted");
|
||||
let listed_after_delete_text = serde_json::to_string(&listed_after_delete).unwrap();
|
||||
assert!(!listed_after_delete_text.contains(raw_key));
|
||||
|
||||
let events = audit_sink.events();
|
||||
let actions: Vec<&str> = events.iter().map(|event| event.action.as_str()).collect();
|
||||
for expected in [
|
||||
"credential.secret.created",
|
||||
"credential.secret.rotated",
|
||||
"credential.auth_profile.created",
|
||||
"credential.secret.delete_denied",
|
||||
"credential.platform_api_key.created",
|
||||
"credential.platform_api_key.create_failed",
|
||||
"credential.auth_profile.create_failed",
|
||||
"credential.platform_api_key.revoked",
|
||||
"credential.platform_api_key.deleted",
|
||||
] {
|
||||
assert!(
|
||||
actions.contains(&expected),
|
||||
"missing audit action {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
let audit_json = serde_json::to_string(&events).unwrap();
|
||||
assert!(audit_json.contains(request_id));
|
||||
assert!(audit_json.contains("11111111111111111111111111111111"));
|
||||
assert!(audit_json.contains(auth_profile_id));
|
||||
assert!(!audit_json.contains("audit-secret-canary"));
|
||||
assert!(!audit_json.contains("audit-rotated-canary"));
|
||||
assert!(!audit_json.contains(raw_key));
|
||||
assert!(!audit_json.contains("secret_hash"));
|
||||
assert!(!audit_json.contains("ciphertext"));
|
||||
assert!(!audit_json.contains("Bearer "));
|
||||
|
||||
for event in &events {
|
||||
let reason = event.payload["reason"]
|
||||
.as_str()
|
||||
.expect("credential audit events must contain a reason code");
|
||||
assert!(!reason.is_empty());
|
||||
assert!(reason.len() <= 128, "audit reason must remain bounded");
|
||||
}
|
||||
assert!(events.iter().any(|event| {
|
||||
event.action == "credential.platform_api_key.create_failed"
|
||||
&& event.payload["reason"] == "validation_error"
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.action == "credential.platform_api_key.create_failed"
|
||||
&& event.payload["reason"] == "platform_api_key_name_conflict"
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn disabled_secret_fails_closed_for_reference_rotation_and_execution() {
|
||||
let registry = test_registry().await;
|
||||
let registry_control = registry.clone();
|
||||
let storage_root = test_storage_root("credential_lifecycle_disabled_secret");
|
||||
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let secret = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "disabled-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "disabled-secret-canary" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "disabled-profile",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
||||
|
||||
let mut operation_payload = serde_json::to_value(test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"disabled_secret_execution",
|
||||
))
|
||||
.unwrap();
|
||||
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
|
||||
let created_operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&operation_payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = created_operation["operation_id"].as_str().unwrap();
|
||||
|
||||
sqlx::query("update secrets set status = 'disabled' where id = $1")
|
||||
.bind(secret_id)
|
||||
.execute(registry_control.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ref_disabled = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "disabled-profile-new-ref",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ref_disabled.status(), reqwest::StatusCode::CONFLICT);
|
||||
let ref_disabled_body: Value =
|
||||
serde_json::from_str(&ref_disabled.text().await.unwrap()).unwrap();
|
||||
assert_eq!(ref_disabled_body["error"]["code"], "secret_not_active");
|
||||
|
||||
let rotate_disabled = client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({
|
||||
"value": { "token": "should-not-rotate" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rotate_disabled.status(), reqwest::StatusCode::CONFLICT);
|
||||
let rotate_body: Value = serde_json::from_str(&rotate_disabled.text().await.unwrap()).unwrap();
|
||||
assert_eq!(rotate_body["error"]["code"], "secret_not_active");
|
||||
|
||||
let test_run = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "disabled@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(test_run["ok"], false);
|
||||
let body = serde_json::to_string(&test_run).unwrap();
|
||||
assert!(!body.contains("disabled-secret-canary"));
|
||||
assert!(observed_authorizations.lock().unwrap().is_empty());
|
||||
|
||||
let operation_get = client
|
||||
.get(format!("{base_url}/operations/{operation_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_etag = operation_get
|
||||
.headers()
|
||||
.get(reqwest::header::ETAG)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let publish_disabled = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/publish"))
|
||||
.header(reqwest::header::IF_MATCH, operation_etag)
|
||||
.json(&json!({ "version": 1 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(publish_disabled.status(), reqwest::StatusCode::CONFLICT);
|
||||
let publish_body: Value =
|
||||
serde_json::from_str(&publish_disabled.text().await.unwrap()).unwrap();
|
||||
assert_eq!(publish_body["error"]["code"], "secret_not_active");
|
||||
assert!(observed_authorizations.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn concurrent_secret_rotation_serializes_versions_without_storage_conflict() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_concurrent_rotation");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let secret = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "concurrent-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "initial-concurrent-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let secret_id = secret["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let first = client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({ "value": { "token": "concurrent-token-a" } }))
|
||||
.send();
|
||||
let second = client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({ "value": { "token": "concurrent-token-b" } }))
|
||||
.send();
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
let first = assert_success_json(first.unwrap()).await;
|
||||
let second = assert_success_json(second.unwrap()).await;
|
||||
assert!(first["current_version"].as_u64().unwrap() >= 2);
|
||||
assert!(second["current_version"].as_u64().unwrap() >= 2);
|
||||
let current = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(current["current_version"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn auth_profile_secret_rotation_changes_next_admin_test_execution() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("credential_lifecycle_rotation");
|
||||
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let secret = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-rotating-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "initial-rotation-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-rotating-bearer",
|
||||
"kind": "bearer",
|
||||
"config": {
|
||||
"bearer": {
|
||||
"header_name": "Authorization",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
||||
|
||||
let mut operation_payload = serde_json::to_value(test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_auth_rotation",
|
||||
))
|
||||
.unwrap();
|
||||
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
|
||||
let created_operation = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&operation_payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let operation_id = created_operation["operation_id"].as_str().unwrap();
|
||||
|
||||
let first_run = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "first@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first_run["ok"], true);
|
||||
|
||||
let rotated = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({
|
||||
"value": { "token": "rotated-rotation-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rotated["current_version"], 2);
|
||||
|
||||
let second_run = assert_success_json(
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "second@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second_run["ok"], true);
|
||||
|
||||
let captured = observed_authorizations.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
captured,
|
||||
vec![
|
||||
Some("Bearer initial-rotation-token".to_owned()),
|
||||
Some("Bearer rotated-rotation-token".to_owned())
|
||||
]
|
||||
);
|
||||
|
||||
let auth_profile_after = client
|
||||
.get(format!("{base_url}/auth-profiles/{auth_profile_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(auth_profile_after.contains(secret_id));
|
||||
assert!(!auth_profile_after.contains("initial-rotation-token"));
|
||||
assert!(!auth_profile_after.contains("rotated-rotation-token"));
|
||||
}
|
||||
|
||||
async fn spawn_auth_capture_upstream() -> (String, Arc<Mutex<Vec<Option<String>>>>) {
|
||||
let observed_authorizations = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = Arc::clone(&observed_authorizations);
|
||||
let app = Router::new().route(
|
||||
"/crm/leads",
|
||||
post(move |headers: HeaderMap| {
|
||||
let captured = Arc::clone(&captured);
|
||||
async move {
|
||||
let authorization = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
captured.lock().unwrap().push(authorization);
|
||||
Json(json!({ "id": "lead_123" }))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(format!("http://{address}"), observed_authorizations)
|
||||
}
|
||||
|
||||
async fn assert_error_code(response: reqwest::Response, status: reqwest::StatusCode, code: &str) {
|
||||
assert_eq!(response.status(), status);
|
||||
let body = response.text().await.unwrap();
|
||||
let json: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["error"]["code"], code, "unexpected error body: {body}");
|
||||
}
|
||||
|
||||
async fn post_json(client: &reqwest::Client, url: String, payload: Value) -> Value {
|
||||
assert_success_json(client.post(url).json(&payload).send().await.unwrap()).await
|
||||
}
|
||||
|
||||
async fn create_agent(client: &reqwest::Client, base_url: impl AsRef<str>, slug: &str) -> String {
|
||||
let base_url = base_url.as_ref();
|
||||
post_json(
|
||||
client,
|
||||
format!("{base_url}/agents"),
|
||||
json!({"slug": slug, "display_name": slug, "description": "Credential lifecycle test agent.", "instructions": {}, "tool_selection_policy": {}}),
|
||||
)
|
||||
.await["agent_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn assert_post_error(
|
||||
client: &reqwest::Client,
|
||||
url: String,
|
||||
payload: Value,
|
||||
status: reqwest::StatusCode,
|
||||
code: &str,
|
||||
) {
|
||||
assert_error_code(
|
||||
client.post(url).json(&payload).send().await.unwrap(),
|
||||
status,
|
||||
code,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exposes_logs_and_usage_from_real_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_observability",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let logs = client
|
||||
.get(format!("{base_url}/logs?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs["items"].is_array());
|
||||
assert!(logs.get("next_cursor").is_some());
|
||||
let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned();
|
||||
let created_at = OffsetDateTime::parse(
|
||||
logs["items"][0]["log"]["created_at"].as_str().unwrap(),
|
||||
&Rfc3339,
|
||||
)
|
||||
.unwrap();
|
||||
let log_detail = client
|
||||
.get(format!("{base_url}/logs/{log_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let csv = client
|
||||
.get(format!("{base_url}/logs/export.csv?period=7d&status=ok"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome_logs = client
|
||||
.get(format!("{base_url}/logs?period=7d&outcome_group=success"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let created_after = (created_at - Duration::minutes(1))
|
||||
.format(&Rfc3339)
|
||||
.unwrap();
|
||||
let created_before = (created_at + Duration::minutes(1))
|
||||
.format(&Rfc3339)
|
||||
.unwrap();
|
||||
let usage = client
|
||||
.get(format!("{base_url}/usage?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let windowed_usage = client
|
||||
.get(format!(
|
||||
"{base_url}/usage?created_after={created_after}&created_before={created_before}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let usage_csv = client
|
||||
.get(format!("{base_url}/usage/export.csv?period=7d"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_usage = client
|
||||
.get(format!(
|
||||
"{base_url}/usage/operations/{operation_id}?period=7d"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run");
|
||||
assert_eq!(logs["items"][0]["operation_name"], "crm_observability");
|
||||
assert_eq!(log_detail["log"]["status"], "ok");
|
||||
assert_eq!(log_detail["log"]["operation_version"], 1);
|
||||
assert!(log_detail["log"]["request_id"].as_str().is_some());
|
||||
assert!(
|
||||
log_detail["log"]["trace_id"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.len() == 32)
|
||||
);
|
||||
assert!(csv.starts_with("created_at,level,status,source"));
|
||||
assert!(csv.contains(log_detail["log"]["request_id"].as_str().unwrap()));
|
||||
assert!(csv.contains(log_detail["log"]["trace_id"].as_str().unwrap()));
|
||||
assert_eq!(outcome_logs["items"][0]["log"]["status"], "ok");
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_total"], 1);
|
||||
assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1);
|
||||
assert_eq!(usage["outcomes"][0]["group"], "success");
|
||||
assert_eq!(usage["outcomes"][0]["calls_total"], 1);
|
||||
assert_eq!(windowed_usage["summary"]["rollup"]["calls_total"], 1);
|
||||
assert!(usage_csv.starts_with("kind,name,group,error_code,calls_total"));
|
||||
assert!(usage_csv.contains("operation"));
|
||||
assert!(usage_csv.contains("outcome"));
|
||||
assert_eq!(
|
||||
usage["operations"][0]["operation_name"],
|
||||
"crm_observability"
|
||||
);
|
||||
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exposes_server_authoritative_onboarding_projection() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("onboarding_projection");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let response = client
|
||||
.get(format!("{base_url}/onboarding"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "body={body}");
|
||||
assert_eq!(body["schema_version"], 1);
|
||||
assert_eq!(body["status"], "in_progress");
|
||||
assert!(body["revision"].as_i64().is_some());
|
||||
assert!(body["eligible_since"].as_str().is_some());
|
||||
assert_eq!(body["workspace_id"], "ws_default");
|
||||
assert_eq!(body["completed"], false);
|
||||
assert_eq!(
|
||||
body["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|step| step["id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"operation",
|
||||
"test",
|
||||
"publish_operation",
|
||||
"agent",
|
||||
"key",
|
||||
"mcp_connection",
|
||||
"first_call",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
body["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|step| step["completed"] == false)
|
||||
);
|
||||
assert_eq!(body["steps"][0]["status"], "current");
|
||||
assert!(
|
||||
body["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.skip(1)
|
||||
.all(|step| step["status"] == "pending")
|
||||
);
|
||||
|
||||
// The server-owned eligibility denominator is recorded on the first GET;
|
||||
// a repeat is idempotent and cannot advance browser-owned completion.
|
||||
let repeated = client
|
||||
.get(format!("{base_url}/onboarding"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated["revision"], body["revision"]);
|
||||
assert_eq!(repeated["steps"], body["steps"]);
|
||||
assert_eq!(repeated["eligible_since"], body["eligible_since"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn ux_milestones_are_idempotent_and_revision_guarded() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("onboarding_events");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let initial = client
|
||||
.get(format!("{base_url}/onboarding"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let revision = initial["revision"].as_i64().unwrap();
|
||||
let payload = json!({
|
||||
"event": "started",
|
||||
"idempotency_key": "onboarding-start-tab-a",
|
||||
"expected_revision": revision,
|
||||
});
|
||||
|
||||
let first = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let first_status = first.status();
|
||||
let first = first.json::<Value>().await.unwrap();
|
||||
assert_eq!(first_status, reqwest::StatusCode::OK, "body={first}");
|
||||
assert_eq!(first["accepted"], true);
|
||||
let accepted_revision = first["revision"].as_i64().unwrap();
|
||||
assert_ne!(accepted_revision, revision);
|
||||
|
||||
let replay = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let replay_status = replay.status();
|
||||
let replay = replay.json::<Value>().await.unwrap();
|
||||
assert_eq!(replay_status, reqwest::StatusCode::OK, "body={replay}");
|
||||
assert_eq!(replay["accepted"], false);
|
||||
assert_eq!(replay["revision"], accepted_revision);
|
||||
|
||||
let semantic_conflict = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&json!({
|
||||
"event": "dismissed",
|
||||
"idempotency_key": "onboarding-start-tab-a",
|
||||
"expected_revision": revision,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let semantic_conflict_status = semantic_conflict.status();
|
||||
let semantic_conflict = semantic_conflict.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
semantic_conflict_status,
|
||||
reqwest::StatusCode::CONFLICT,
|
||||
"body={semantic_conflict}"
|
||||
);
|
||||
assert_eq!(
|
||||
semantic_conflict["error"]["code"],
|
||||
"onboarding_idempotency_conflict"
|
||||
);
|
||||
|
||||
let current_revision_conflict = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&json!({
|
||||
"event": "dismissed",
|
||||
"idempotency_key": "onboarding-start-tab-a",
|
||||
"expected_revision": accepted_revision,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let current_revision_conflict_status = current_revision_conflict.status();
|
||||
let current_revision_conflict = current_revision_conflict.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
current_revision_conflict_status,
|
||||
reqwest::StatusCode::CONFLICT,
|
||||
"body={current_revision_conflict}"
|
||||
);
|
||||
assert_eq!(
|
||||
current_revision_conflict["error"]["code"],
|
||||
"onboarding_idempotency_conflict"
|
||||
);
|
||||
|
||||
let stale = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&json!({
|
||||
"event": "dismissed",
|
||||
"idempotency_key": "onboarding-dismiss-tab-b",
|
||||
"expected_revision": revision,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let stale_status = stale.status();
|
||||
let stale = stale.json::<Value>().await.unwrap();
|
||||
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
|
||||
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
|
||||
|
||||
for payload in [
|
||||
json!({
|
||||
"event": "eligible",
|
||||
"idempotency_key": "forged-eligibility",
|
||||
"expected_revision": accepted_revision,
|
||||
}),
|
||||
json!({
|
||||
"event": "completed",
|
||||
"idempotency_key": "forged-completion",
|
||||
"expected_revision": accepted_revision,
|
||||
}),
|
||||
json!({
|
||||
"event": "started",
|
||||
"idempotency_key": "forged-step",
|
||||
"expected_revision": accepted_revision,
|
||||
"completed_steps": ["operation", "first_call"],
|
||||
}),
|
||||
json!({
|
||||
"event": "started",
|
||||
"idempotency_key": "onboarding:completed:v1",
|
||||
"expected_revision": accepted_revision,
|
||||
}),
|
||||
] {
|
||||
let response = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"body={body}"
|
||||
);
|
||||
assert_eq!(body["error"]["code"], "onboarding_event_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn direct_presentation_mutation_cannot_precede_server_eligibility() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("onboarding_direct_event");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let direct = client
|
||||
.post(format!("{base_url}/onboarding/events"))
|
||||
.json(&json!({
|
||||
"event": "started",
|
||||
"idempotency_key": "direct-before-snapshot",
|
||||
"expected_revision": 0,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let direct_status = direct.status();
|
||||
let direct = direct.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
direct_status,
|
||||
reqwest::StatusCode::CONFLICT,
|
||||
"body={direct}"
|
||||
);
|
||||
assert_eq!(direct["error"]["code"], "onboarding_stale_revision");
|
||||
|
||||
let snapshot = client
|
||||
.get(format!("{base_url}/onboarding"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(snapshot["eligible_since"].as_str().is_some());
|
||||
assert_eq!(snapshot["status"], "in_progress");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn reset_selection_is_explicit_and_revision_guarded() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("onboarding_reset_selection");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let initial = client
|
||||
.get(format!("{base_url}/onboarding"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let revision = initial["revision"].as_i64().unwrap();
|
||||
|
||||
let reset = client
|
||||
.post(format!("{base_url}/onboarding/reset-selection"))
|
||||
.json(&json!({"expected_revision": revision}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let reset_status = reset.status();
|
||||
let reset = reset.json::<Value>().await.unwrap();
|
||||
assert_eq!(reset_status, reqwest::StatusCode::OK, "body={reset}");
|
||||
assert_eq!(reset["selection_reset"], true);
|
||||
assert_ne!(reset["revision"], initial["revision"]);
|
||||
assert_eq!(reset["workspace_id"], initial["workspace_id"]);
|
||||
|
||||
let stale = client
|
||||
.post(format!("{base_url}/onboarding/reset-selection"))
|
||||
.json(&json!({"expected_revision": revision}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let stale_status = stale.status();
|
||||
let stale = stale.json::<Value>().await.unwrap();
|
||||
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
|
||||
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
|
||||
|
||||
let forged_selection = client
|
||||
.post(format!("{base_url}/onboarding/reset-selection"))
|
||||
.json(&json!({
|
||||
"expected_revision": reset["revision"],
|
||||
"operation_id": "op_client_selected",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
forged_selection.status(),
|
||||
reqwest::StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
|
||||
use admin_api::service::{
|
||||
AdminServiceBuilder, OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale,
|
||||
};
|
||||
use crank_core::WorkspaceId;
|
||||
use crank_registry::ImportJobStatus;
|
||||
use serial_test::serial;
|
||||
@@ -7,6 +9,8 @@ use super::common::{
|
||||
test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root,
|
||||
};
|
||||
|
||||
mod external_references;
|
||||
|
||||
const OPENAPI3: &str = r#"
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
@@ -51,12 +55,7 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -65,12 +64,26 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
preview.preview.groups[0].operations[0].suggested_name,
|
||||
"latest_rates"
|
||||
);
|
||||
let public_response = serde_json::to_string(&preview).unwrap();
|
||||
assert!(!public_response.contains("normalizer_version"));
|
||||
assert!(!public_response.contains("projection_version"));
|
||||
assert!(!public_response.contains("ir_fingerprint"));
|
||||
assert!(!public_response.contains("source_identity"));
|
||||
let preview_job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview_job.status, ImportJobStatus::Pending);
|
||||
assert_eq!(
|
||||
preview_job.preview_payload["normalization"]["normalizer_version"],
|
||||
"normalized-ir-v3"
|
||||
);
|
||||
assert_eq!(
|
||||
preview_job.preview_payload["normalization"]["projection_version"],
|
||||
"preview-v3"
|
||||
);
|
||||
assert!(preview_job.preview_payload["normalization"]["ir_fingerprint"].is_string());
|
||||
|
||||
let created = service
|
||||
.create_openapi_import(
|
||||
@@ -86,9 +99,28 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.created.len(), 1);
|
||||
assert_eq!(created.created[0].operation_key, "GET /v2/latest");
|
||||
assert_eq!(created.created[0].name, "latest_rates");
|
||||
assert!(created.skipped.is_empty());
|
||||
|
||||
let replayed = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replayed.created.len(), 1);
|
||||
assert_eq!(
|
||||
replayed.created[0].operation_id,
|
||||
created.created[0].operation_id
|
||||
);
|
||||
|
||||
let operations = service.list_operations(&workspace_id).await.unwrap();
|
||||
assert!(
|
||||
operations
|
||||
@@ -120,12 +152,7 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
);
|
||||
|
||||
let skip_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let skipped = service
|
||||
@@ -147,12 +174,7 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
assert_eq!(skipped.findings[0].code, "operation_name_conflict");
|
||||
|
||||
let rename_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let renamed = service
|
||||
@@ -175,22 +197,57 @@ async fn previews_openapi_and_creates_draft_operations() {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
async fn unknown_selected_operations_are_persisted_in_the_canonical_replay() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
test_storage_root("openapi_import_unknown_replay"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let payload = OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned(), "GET /missing".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
};
|
||||
|
||||
let first = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let replayed = service
|
||||
.create_openapi_import(&workspace_id, &preview.job_id.as_str().into(), payload)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.created, replayed.created);
|
||||
assert_eq!(first.skipped, replayed.skipped);
|
||||
assert_eq!(first.skipped.len(), 1);
|
||||
assert_eq!(first.skipped[0].operation_key, "GET /missing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_replay"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id = preview.job_id.as_str().into();
|
||||
@@ -218,6 +275,31 @@ async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
service.list_operations(&workspace_id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
let completed_before = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let empty = serde_json::json!([]);
|
||||
let failed_at = time::OffsetDateTime::now_utc();
|
||||
registry
|
||||
.finish_import_job(crank_registry::FinishImportJobRequest {
|
||||
id: &job_id,
|
||||
status: ImportJobStatus::Failed,
|
||||
created_operation_ids: &empty,
|
||||
error_text: Some("late_verification_failure"),
|
||||
finished_at: &failed_at,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let completed_after = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(completed_after.status, ImportJobStatus::Completed);
|
||||
assert_eq!(completed_after.error_text, completed_before.error_text);
|
||||
assert_eq!(completed_after.finished_at, completed_before.finished_at);
|
||||
|
||||
let conflicting_replay = service
|
||||
.create_openapi_import(
|
||||
@@ -232,3 +314,468 @@ async fn concurrent_openapi_import_replays_the_same_atomic_result() {
|
||||
.await;
|
||||
assert!(conflicting_replay.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn apply_fails_closed_when_the_preview_parser_contract_drifts() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_parser_drift"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
sqlx::query(
|
||||
"update import_jobs
|
||||
set preview_payload = jsonb_set(preview_payload, '{preview_digest}', to_jsonb($1::text))
|
||||
where id = $2",
|
||||
)
|
||||
.bind("0".repeat(64))
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_failed_job_and_detached_sources(
|
||||
®istry,
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
"import_replay_verification_failed",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn apply_fails_closed_for_each_normalization_contract_field_and_digest_shape() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_contract_fields"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
for case in [
|
||||
"normalizer_version",
|
||||
"projection_version",
|
||||
"ir_fingerprint",
|
||||
"missing_preview_digest",
|
||||
"non_string_preview_digest",
|
||||
] {
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
let query = match case {
|
||||
"normalizer_version" => sqlx::query(
|
||||
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,normalizer_version}', to_jsonb('future'::text)) where id = $1",
|
||||
),
|
||||
"projection_version" => sqlx::query(
|
||||
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,projection_version}', to_jsonb('future'::text)) where id = $1",
|
||||
),
|
||||
"ir_fingerprint" => sqlx::query(
|
||||
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,ir_fingerprint}', to_jsonb('bad'::text)) where id = $1",
|
||||
),
|
||||
"missing_preview_digest" => sqlx::query(
|
||||
"update import_jobs set preview_payload = preview_payload - 'preview_digest' where id = $1",
|
||||
),
|
||||
"non_string_preview_digest" => sqlx::query(
|
||||
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview_digest}', '42'::jsonb) where id = $1",
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
query
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_failed_job_and_detached_sources(
|
||||
®istry,
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
"import_replay_verification_failed",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn legacy_job_without_normalization_contract_replays_until_expiry() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_legacy_replay"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
sqlx::query(
|
||||
"update import_jobs set preview_payload = preview_payload - 'normalization' where id = $1",
|
||||
)
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.created.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn blocker_finding_keeps_valid_preview_but_prevents_draft_mutation() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_blocker"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let upload = OpenApiUpload {
|
||||
bytes: br#"
|
||||
openapi: 3.0.3
|
||||
info: { title: Partial }
|
||||
paths:
|
||||
/valid:
|
||||
get:
|
||||
responses: { '200': { description: ok } }
|
||||
/broken: not-a-path-item
|
||||
"#
|
||||
.to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
};
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, upload)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preview.preview.groups[0].operations.len(), 1);
|
||||
assert!(
|
||||
preview
|
||||
.preview
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.severity == crank_import::rest::ImportFindingSeverity::Error)
|
||||
);
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /valid".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_failed_job_and_detached_sources(
|
||||
®istry,
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
"reference_resolution_blocked",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn operation_blockers_apply_only_to_selected_keys_and_full_selection_fails_atomically() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_selected_blockers"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let upload = OpenApiUpload {
|
||||
bytes: br#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Selected blockers }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/valid:
|
||||
get:
|
||||
operationId: validOperation
|
||||
responses: { '200': { description: ok } }
|
||||
/broken:
|
||||
get:
|
||||
operationId: brokenOperation
|
||||
responses:
|
||||
'200':
|
||||
description: unresolved external schema
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: 'https://schemas.example.test/missing.yaml#/Result' }
|
||||
"#
|
||||
.to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
};
|
||||
|
||||
let valid_preview = service
|
||||
.preview_openapi_import(&workspace_id, upload.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
valid_preview.preview.findings.iter().all(|finding| {
|
||||
finding.severity != crank_import::rest::ImportFindingSeverity::Error
|
||||
})
|
||||
);
|
||||
let broken = valid_preview
|
||||
.preview
|
||||
.groups
|
||||
.iter()
|
||||
.flat_map(|group| &group.operations)
|
||||
.find(|operation| operation.key == "GET /broken")
|
||||
.unwrap();
|
||||
assert!(
|
||||
broken.findings.iter().any(|finding| {
|
||||
finding.severity == crank_import::rest::ImportFindingSeverity::Error
|
||||
})
|
||||
);
|
||||
|
||||
let valid = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&valid_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /valid".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(valid.created.len(), 1);
|
||||
|
||||
let full_preview = service
|
||||
.preview_openapi_import(&workspace_id, upload)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&full_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /valid".to_owned(), "GET /broken".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "rename".to_owned(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
service.list_operations(&workspace_id).await.unwrap().len(),
|
||||
1,
|
||||
"the full selection must not create either selected Draft"
|
||||
);
|
||||
let failed = registry
|
||||
.get_import_job(&workspace_id, &full_preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(failed.status, ImportJobStatus::Failed);
|
||||
assert_eq!(
|
||||
failed.error_text.as_deref(),
|
||||
Some("reference_resolution_blocked")
|
||||
);
|
||||
assert!(failed.finished_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn local_operation_reference_resolves_and_creates_draft() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
test_storage_root("openapi_import_operation_blocker"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let upload = OpenApiUpload {
|
||||
bytes: br#"
|
||||
openapi: 3.0.3
|
||||
info: { title: References }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/referenced:
|
||||
get:
|
||||
operationId: referenced
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Result' }
|
||||
components:
|
||||
schemas:
|
||||
Result: { type: object, properties: { id: { type: string } } }
|
||||
"#
|
||||
.to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
};
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, upload)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
preview.preview.findings.iter().all(|finding| {
|
||||
finding.severity != crank_import::rest::ImportFindingSeverity::Error
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
preview.preview.groups[0].operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.all(|finding| finding.code != "unresolved_reference")
|
||||
);
|
||||
assert_eq!(preview.preview.groups[0].operations[0].output_fields, 1);
|
||||
|
||||
let created = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /referenced".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.created.len(), 1);
|
||||
assert_eq!(
|
||||
service.list_operations(&workspace_id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
fn openapi_upload() -> OpenApiUpload {
|
||||
OpenApiUpload {
|
||||
bytes: OPENAPI3.as_bytes().to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_failed_job_and_detached_sources(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
workspace_id: &WorkspaceId,
|
||||
job_id: &crank_registry::ImportJobId,
|
||||
expected_error: &str,
|
||||
) {
|
||||
let job = registry
|
||||
.get_import_job(workspace_id, job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(job.status, ImportJobStatus::Failed);
|
||||
assert!(job.finished_at.is_some());
|
||||
assert_eq!(job.error_text.as_deref(), Some(expected_error));
|
||||
|
||||
let mut source_ids = vec![
|
||||
job.preview_payload["source"]["source_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
];
|
||||
source_ids.extend(
|
||||
job.preview_payload["dependencies"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|dependency| dependency["source_id"].as_str().unwrap().to_owned()),
|
||||
);
|
||||
for source_id in source_ids {
|
||||
let lifecycle: String = sqlx::query_scalar(
|
||||
"select lifecycle from artifact_sources where workspace_id = $1 and source_id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(source_id)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(lifecycle, "detached");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
use super::super::common::test_service_with_external_references;
|
||||
use super::*;
|
||||
use axum::{Router, routing::get};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn external_relative_chain_survives_reversed_builder_order_and_apply_never_refetches() {
|
||||
let fetches = Arc::new(AtomicUsize::new(0));
|
||||
let root_fetches = Arc::clone(&fetches);
|
||||
let child_fetches = Arc::clone(&fetches);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/root.yaml",
|
||||
get(move || {
|
||||
let fetches = Arc::clone(&root_fetches);
|
||||
async move {
|
||||
fetches.fetch_add(1, Ordering::SeqCst);
|
||||
"Item: { $ref: './child.yaml#/Item' }"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/child.yaml",
|
||||
get(move || {
|
||||
let fetches = Arc::clone(&child_fetches);
|
||||
async move {
|
||||
fetches.fetch_add(1, Ordering::SeqCst);
|
||||
"Item: { type: object, required: [id], properties: { id: { type: string } } }"
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let origin = format!("http://{address}");
|
||||
|
||||
let registry = test_registry().await;
|
||||
let service = test_service_with_external_references(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_external_snapshot"),
|
||||
vec![format!("{origin}/")],
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let upload = OpenApiUpload {
|
||||
bytes: format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: External }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema: {{ $ref: '{origin}/root.yaml#/Item' }}
|
||||
"#
|
||||
)
|
||||
.into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
};
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, upload)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fetches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(preview.preview.groups[0].operations[0].output_fields, 1);
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job.preview_payload["dependencies"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
job.preview_payload["dependency_snapshots"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let applied = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /items".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(applied.created.len(), 1);
|
||||
assert_eq!(fetches.load(Ordering::SeqCst), 2);
|
||||
let active_sources: i64 = sqlx::query_scalar(
|
||||
"select count(*) from artifact_sources
|
||||
where source_id like 'src_openapi_%' and lifecycle = 'active'",
|
||||
)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active_sources, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn missing_external_snapshot_fails_closed_before_draft_mutation() {
|
||||
let app = Router::new().route(
|
||||
"/schemas.yaml",
|
||||
get(|| async { "Item: { type: object, properties: { id: { type: string } } }" }),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let origin = format!("http://{address}");
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("openapi_import_missing_external_snapshot");
|
||||
let service = test_service_with_external_references(
|
||||
registry.clone(),
|
||||
storage_root.clone(),
|
||||
vec![format!("{origin}/")],
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: External integrity }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
|
||||
"#
|
||||
)
|
||||
.into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let dependency_source_id = job.preview_payload["dependencies"][0]["source_id"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"update artifact_sources
|
||||
set lifecycle = 'detached', updated_at = now(), detached_at = now()
|
||||
where workspace_id = $1 and source_id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(dependency_source_id)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /items".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_failed_job_and_detached_sources(
|
||||
®istry,
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
"import_dependency_verification_failed",
|
||||
)
|
||||
.await;
|
||||
|
||||
let corrupt_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: Corrupt external integrity }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItemsCorrupt
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
|
||||
"#
|
||||
)
|
||||
.into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let corrupt_job = registry
|
||||
.get_import_job(&workspace_id, &corrupt_preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let dependency_ref: crank_artifacts::ArtifactRef = corrupt_job.preview_payload["dependencies"]
|
||||
[0]["digest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let dependency_path = storage_root
|
||||
.join("sha256")
|
||||
.join(&dependency_ref.digest_hex()[..2])
|
||||
.join(dependency_ref.digest_hex());
|
||||
std::fs::set_permissions(
|
||||
&dependency_path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o600),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&dependency_path, b"corrupt external dependency").unwrap();
|
||||
std::fs::set_permissions(
|
||||
&dependency_path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o400),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&corrupt_preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /items".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_failed_job_and_detached_sources(
|
||||
®istry,
|
||||
&workspace_id,
|
||||
&corrupt_preview.job_id.as_str().into(),
|
||||
"import_dependency_verification_failed",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn external_materialization_uses_one_wall_clock_timeout_and_cleans_cancelled_sources() {
|
||||
let fetches = Arc::new(AtomicUsize::new(0));
|
||||
let root_fetches = Arc::clone(&fetches);
|
||||
let child_fetches = Arc::clone(&fetches);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/root.yaml",
|
||||
get(move || {
|
||||
let fetches = Arc::clone(&root_fetches);
|
||||
async move {
|
||||
fetches.fetch_add(1, Ordering::SeqCst);
|
||||
"Item: { $ref: './slow-child.yaml#/Item' }"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/slow-child.yaml",
|
||||
get(move || {
|
||||
let fetches = Arc::clone(&child_fetches);
|
||||
async move {
|
||||
fetches.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
"Item: { type: object, properties: { id: { type: string } } }"
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let origin = format!("http://{address}");
|
||||
|
||||
let registry = test_registry().await;
|
||||
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
|
||||
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_chain_timeout"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
runtime,
|
||||
)
|
||||
.with_external_reference_import(&crank_config::ExternalReferenceSettings {
|
||||
allowed_url_prefixes: vec![format!("{origin}/")],
|
||||
max_depth: 8,
|
||||
max_documents: 32,
|
||||
max_fetch_bytes: 64 * 1024,
|
||||
fetch_timeout_ms: 200,
|
||||
max_expanded_nodes: 10_000,
|
||||
})
|
||||
.unwrap()
|
||||
.with_outbound_http_policy(outbound_policy)
|
||||
.build();
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let started = tokio::time::Instant::now();
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: Timed chain }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: timedItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: {{ application/json: {{ schema: {{ $ref: '{origin}/root.yaml#/Item' }} }} }}
|
||||
"#
|
||||
)
|
||||
.into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(800));
|
||||
assert_eq!(fetches.load(Ordering::SeqCst), 2);
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job.preview_payload["dependencies"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
for _ in 0..100 {
|
||||
let active_dependencies: i64 = sqlx::query_scalar(
|
||||
"select count(*) from artifact_sources
|
||||
where source_id like 'src_openapi_dep_%' and lifecycle = 'active'",
|
||||
)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
if active_dependencies == 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
let detached_dependencies: i64 = sqlx::query_scalar(
|
||||
"select count(*) from artifact_sources
|
||||
where source_id like 'src_openapi_dep_%' and lifecycle = 'detached'",
|
||||
)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached_dependencies, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn expired_job_detaches_primary_and_external_dependency_without_orphan() {
|
||||
let app = Router::new().route(
|
||||
"/schemas.yaml",
|
||||
get(|| async { "Item: { type: object, properties: { id: { type: string } } }" }),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let origin = format!("http://{address}");
|
||||
let registry = test_registry().await;
|
||||
let service = test_service_with_external_references(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_external_expiry"),
|
||||
vec![format!("{origin}/")],
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: Expiring external }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
|
||||
"#
|
||||
)
|
||||
.into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("update import_jobs set expires_at = now() - interval '1 second' where id = $1")
|
||||
.bind(preview.job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let report = registry.cleanup_expired_import_jobs(16).await.unwrap();
|
||||
assert_eq!(report.deleted_jobs, 1);
|
||||
assert_eq!(report.detached_sources, 2);
|
||||
let active_sources: i64 = sqlx::query_scalar(
|
||||
"select count(*) from artifact_sources
|
||||
where source_id like 'src_openapi_%' and lifecycle = 'active'",
|
||||
)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active_sources, 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn exact_v2_contract_replays_persisted_preview_without_v3_or_dependency_reads() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_import_v2_compatibility"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
let missing_dependency = serde_json::json!([{
|
||||
"source_id": "src_openapi_dep_v2_must_not_be_read",
|
||||
"digest": format!("sha256:{}", "0".repeat(64)),
|
||||
"canonical_uri": "https://schemas.example.test/v2.yaml"
|
||||
}]);
|
||||
sqlx::query(
|
||||
"update import_jobs
|
||||
set preview_payload = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
preview_payload,
|
||||
'{normalization,normalizer_version}',
|
||||
to_jsonb('normalized-ir-v2'::text)
|
||||
),
|
||||
'{normalization,projection_version}',
|
||||
to_jsonb('preview-v2'::text)
|
||||
),
|
||||
'{normalization,ir_fingerprint}',
|
||||
to_jsonb($1::text)
|
||||
),
|
||||
'{dependency_snapshots}',
|
||||
$2::jsonb
|
||||
)
|
||||
where id = $3",
|
||||
)
|
||||
.bind("a".repeat(64))
|
||||
.bind(missing_dependency)
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.created.len(), 1);
|
||||
let completed = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(completed.status, ImportJobStatus::Completed);
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
use super::common::{
|
||||
authorized_client, build_test_app, spawn_admin_api, test_auth_settings, test_registry,
|
||||
test_secret_crypto, test_service, test_storage_root,
|
||||
};
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale};
|
||||
use crank_artifacts::MAX_ARTIFACT_BYTES;
|
||||
use crank_core::{Workspace, WorkspaceId, WorkspaceStatus};
|
||||
use crank_registry::{ArtifactSourceId, ArtifactSourceLifecycle, CreateWorkspaceRequest};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
mod apply_failures;
|
||||
mod telemetry;
|
||||
|
||||
const OPENAPI: &str = r#"
|
||||
openapi: 3.0.3
|
||||
info: { title: Source authority }
|
||||
servers:
|
||||
- url: https://example.test
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
operationId: sourceHealth
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn multipart_accepts_valid_yaml_and_json_through_the_outer_router() {
|
||||
let registry = test_registry().await;
|
||||
let app = build_test_app(registry, test_storage_root("openapi_valid_multipart"));
|
||||
let server = spawn_admin_api(app).await;
|
||||
let client = authorized_client(&server).await;
|
||||
|
||||
for (document, filename, mime_type) in [
|
||||
(OPENAPI.as_bytes(), "openapi.yaml", "application/yaml"),
|
||||
(
|
||||
br#"{"openapi":"3.0.3","info":{"title":"JSON"},"paths":{"/health":{"get":{"responses":{"200":{"description":"OK"}}}}}}"#
|
||||
.as_slice(),
|
||||
"openapi.json",
|
||||
"application/json",
|
||||
),
|
||||
] {
|
||||
let response = client
|
||||
.post(format!("{server}/imports/openapi/preview"))
|
||||
.multipart(Form::new().part(
|
||||
"file",
|
||||
file_part(document, filename, mime_type),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(status, reqwest::StatusCode::OK, "{body}");
|
||||
assert!(body["job_id"].is_string());
|
||||
assert!(!body.to_string().contains("source_id"));
|
||||
assert!(!body.to_string().contains("digest"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn multipart_requires_an_owner_membership_before_reading_the_file() {
|
||||
let registry = test_registry().await;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let foreign_workspace = WorkspaceId::new("ws_openapi_unauthorized");
|
||||
registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &Workspace {
|
||||
id: foreign_workspace.clone(),
|
||||
slug: "openapi-unauthorized".to_owned(),
|
||||
display_name: "OpenAPI Unauthorized".to_owned(),
|
||||
status: WorkspaceStatus::Active,
|
||||
settings: serde_json::json!({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_test_app(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_upload_authorization"),
|
||||
);
|
||||
let server = spawn_admin_api(app).await;
|
||||
let endpoint = format!("{server}/imports/openapi/preview");
|
||||
|
||||
let anonymous = reqwest::Client::new()
|
||||
.post(&endpoint)
|
||||
.header("content-type", "multipart/form-data; boundary=broken")
|
||||
.body("this body must not be parsed before authentication")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let authorized = authorized_client(&server).await;
|
||||
let foreign_endpoint = endpoint.replace("ws_default", foreign_workspace.as_str());
|
||||
let forbidden = authorized
|
||||
.post(foreign_endpoint)
|
||||
.header("content-type", "multipart/form-data; boundary=broken")
|
||||
.body("this body must not be parsed before workspace authorization")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from artifact_sources")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from import_jobs")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn multipart_boundary_rejections_are_localized_and_leave_no_entities() {
|
||||
let registry = test_registry().await;
|
||||
let app = build_test_app(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_multipart_boundary"),
|
||||
);
|
||||
let server = spawn_admin_api(app).await;
|
||||
let client = authorized_client(&server).await;
|
||||
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(b"sensitive-openapi-body", "openapi.txt", "text/plain"),
|
||||
),
|
||||
"ru-RU",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.invalid_media_type",
|
||||
Some("тип"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(OPENAPI.as_bytes(), "openapi.txt", "application/yaml"),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.invalid_media_type",
|
||||
Some("not supported"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(OPENAPI.as_bytes(), "openapi.yaml", "application/json"),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.invalid_media_type",
|
||||
Some("not supported"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new(),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.malformed_multipart",
|
||||
Some("multipart"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from import_jobs")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from operations")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"other",
|
||||
file_part(OPENAPI.as_bytes(), "openapi.yaml", "application/yaml"),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.malformed_multipart",
|
||||
Some("multipart"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new()
|
||||
.part(
|
||||
"file",
|
||||
file_part(OPENAPI.as_bytes(), "one.yaml", "application/yaml"),
|
||||
)
|
||||
.part(
|
||||
"file",
|
||||
file_part(OPENAPI.as_bytes(), "two.yaml", "application/yaml"),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.malformed_multipart",
|
||||
Some("multipart"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part("file", file_part(b"", "openapi.yaml", "application/yaml")),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.empty_file",
|
||||
Some("empty"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part([0xff, 0xfe], "openapi.yaml", "application/yaml"),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.invalid_utf8",
|
||||
Some("UTF-8"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(
|
||||
vec![b'x'; MAX_ARTIFACT_BYTES + 1],
|
||||
"openapi.yaml",
|
||||
"application/yaml",
|
||||
),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"openapi_upload.file_too_large",
|
||||
Some("256 KiB"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from import_jobs")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from artifact_sources")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(
|
||||
b"openapi: 3.0.3\ninfo: { title: Empty }\npaths: {}\n",
|
||||
"openapi.yaml",
|
||||
"application/yaml",
|
||||
),
|
||||
),
|
||||
"en-US",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.no_methods",
|
||||
Some("no supported methods"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from artifact_sources where lifecycle = 'active'",
|
||||
)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0,
|
||||
"no_methods must not leave an active source behind",
|
||||
);
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part("file", file_part(b"", "openapi.yaml", "application/yaml")),
|
||||
"ru-RU",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.empty_file",
|
||||
Some("пуст"),
|
||||
)
|
||||
.await;
|
||||
assert_rejected(
|
||||
&client,
|
||||
&server,
|
||||
Form::new().part(
|
||||
"file",
|
||||
file_part(b"openapi: [", "openapi.yaml", "application/yaml"),
|
||||
),
|
||||
"ru-RU",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"openapi_upload.invalid_document",
|
||||
Some("документ"),
|
||||
)
|
||||
.await;
|
||||
wait_for_source_lifecycle(®istry, ArtifactSourceLifecycle::Detached).await;
|
||||
|
||||
let mut exact_limit = OPENAPI.as_bytes().to_vec();
|
||||
let padding = MAX_ARTIFACT_BYTES
|
||||
.checked_sub(exact_limit.len())
|
||||
.expect("OpenAPI fixture must fit inside the exact-size boundary");
|
||||
exact_limit.extend(std::iter::repeat_n(b'#', padding));
|
||||
let response = client
|
||||
.post(format!("{server}/imports/openapi/preview"))
|
||||
.multipart(Form::new().part(
|
||||
"file",
|
||||
file_part(&exact_limit, "openapi.yaml", "application/yaml"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
let rendered = body.to_string();
|
||||
assert!(body["job_id"].is_string());
|
||||
assert!(!rendered.contains("source_id"));
|
||||
assert!(!rendered.contains("digest"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn cleanup_is_bounded_and_malformed_expired_jobs_do_not_block_later_rows() {
|
||||
let registry = test_registry().await;
|
||||
for index in 0..129 {
|
||||
sqlx::query(
|
||||
"insert into import_jobs (
|
||||
id, workspace_id, kind, source_format, source_version, status,
|
||||
preview_payload, created_operation_ids, error_text, created_at, expires_at, finished_at
|
||||
) values (
|
||||
$1, 'ws_default', 'openapi', 'openapi', null, 'pending',
|
||||
'{\"source\": {\"source_id\": 1}}'::jsonb, '[]'::jsonb, null,
|
||||
now() - interval '2 hours', now() - interval '1 hour', null
|
||||
)",
|
||||
)
|
||||
.bind(format!("imp_cleanup_{index:03}"))
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let first = registry.cleanup_expired_import_jobs(128).await.unwrap();
|
||||
assert_eq!(first.deleted_jobs, 128);
|
||||
assert!(first.more_work);
|
||||
let second = registry.cleanup_expired_import_jobs(128).await.unwrap();
|
||||
assert_eq!(second.deleted_jobs, 1);
|
||||
assert!(!second.more_work);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from import_jobs")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_source_lifecycle(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
lifecycle: ArtifactSourceLifecycle,
|
||||
) {
|
||||
let expected = match lifecycle {
|
||||
ArtifactSourceLifecycle::Active => "active",
|
||||
ArtifactSourceLifecycle::Detached => "detached",
|
||||
};
|
||||
for _ in 0..100 {
|
||||
let found = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(select 1 from artifact_sources where lifecycle = $1)",
|
||||
)
|
||||
.bind(expected)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
if found {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("artifact source did not reach {expected}");
|
||||
}
|
||||
|
||||
fn file_part(bytes: impl AsRef<[u8]>, filename: &str, mime_type: &str) -> Part {
|
||||
Part::bytes(bytes.as_ref().to_vec())
|
||||
.file_name(filename.to_owned())
|
||||
.mime_str(mime_type)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn assert_rejected(
|
||||
client: &reqwest::Client,
|
||||
server: &impl AsRef<str>,
|
||||
form: Form,
|
||||
language: &str,
|
||||
expected_status: reqwest::StatusCode,
|
||||
expected_code: &str,
|
||||
message_fragment: Option<&str>,
|
||||
) {
|
||||
let response = client
|
||||
.post(format!("{}/imports/openapi/preview", server.as_ref()))
|
||||
.header("accept-language", language)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(
|
||||
status, expected_status,
|
||||
"unexpected status while checking {expected_code}: {body}"
|
||||
);
|
||||
assert_eq!(body["error"]["code"], expected_code);
|
||||
assert!(body["error"]["request_id"].is_string());
|
||||
assert!(body["error"]["trace_id"].is_string());
|
||||
if let Some(fragment) = message_fragment {
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(fragment)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn apply_rereads_the_scoped_verified_source_and_expiry_detaches_it() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_verified_source"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let service = service.clone();
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: OPENAPI.as_bytes().to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id = preview.job_id.as_str().into();
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source_id = job.preview_payload["source"]["source_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let source = registry
|
||||
.get_artifact_source(&workspace_id, &source_id.as_str().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source.lifecycle, ArtifactSourceLifecycle::Active);
|
||||
|
||||
// The stored preview is presentation data only. A forged candidate cannot
|
||||
// create a Draft because apply reparses the source bytes.
|
||||
sqlx::query("update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview}', '{\"groups\":[]}') where id = $1")
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let imported = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /health".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imported.created.len(), 1);
|
||||
let detached = registry
|
||||
.get_artifact_source(&workspace_id, &source_id.as_str().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detached.lifecycle, ArtifactSourceLifecycle::Detached);
|
||||
|
||||
let second_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: OPENAPI.as_bytes().to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let second_job_id = second_preview.job_id.as_str().into();
|
||||
let second_job = registry
|
||||
.get_import_job(&workspace_id, &second_job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let second_source_id = second_job.preview_payload["source"]["source_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
sqlx::query("update import_jobs set expires_at = $1 where id = $2")
|
||||
.bind(OffsetDateTime::now_utc() - Duration::minutes(1))
|
||||
.bind(second_job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
registry.delete_expired_import_jobs().await.unwrap();
|
||||
let expired_job_source = registry
|
||||
.get_artifact_source(&workspace_id, &second_source_id.as_str().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
expired_job_source.lifecycle,
|
||||
ArtifactSourceLifecycle::Detached
|
||||
);
|
||||
|
||||
let legacy_preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!("{OPENAPI}\n# legacy-upgrade").into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let legacy_job_id: crank_registry::ImportJobId = legacy_preview.job_id.as_str().into();
|
||||
let legacy_job = registry
|
||||
.get_import_job(&workspace_id, &legacy_job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let legacy_source_id = legacy_job.preview_payload["source"]["source_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
sqlx::query(
|
||||
"update import_jobs
|
||||
set preview_payload = jsonb_set(
|
||||
preview_payload,
|
||||
'{source}',
|
||||
'{\"format\":\"openapi\",\"version\":\"3.0.3\"}'::jsonb
|
||||
),
|
||||
expires_at = now() - interval '1 minute'
|
||||
where id = $1",
|
||||
)
|
||||
.bind(legacy_job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"update artifact_sources
|
||||
set created_at = now() - interval '10 minutes',
|
||||
updated_at = now() - interval '10 minutes'
|
||||
where workspace_id = $1 and source_id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(&legacy_source_id)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry.delete_expired_import_jobs().await.unwrap();
|
||||
assert!(
|
||||
registry
|
||||
.get_import_job(&workspace_id, &legacy_job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.get_artifact_source(&workspace_id, &legacy_source_id.as_str().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.lifecycle,
|
||||
ArtifactSourceLifecycle::Detached
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn cancellation_detaches_and_restart_cleanup_recovers_a_dangling_source() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
test_storage_root("openapi_cancel_restart"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let schema = sqlx::query_scalar::<_, String>("select current_schema()")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let observer = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(crank_test_support::postgres_database_url().await)
|
||||
.await
|
||||
.unwrap();
|
||||
let lock_pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(crank_test_support::postgres_database_url().await)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("select set_config('search_path', $1, false)")
|
||||
.bind(&schema)
|
||||
.execute(&observer)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("select set_config('search_path', $1, false)")
|
||||
.bind(&schema)
|
||||
.execute(&lock_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"create function block_openapi_import_insert() returns trigger language plpgsql as $$
|
||||
begin
|
||||
perform pg_advisory_xact_lock(2147483001);
|
||||
return new;
|
||||
end $$",
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"create trigger block_openapi_import_insert
|
||||
before insert on import_jobs
|
||||
for each row execute function block_openapi_import_insert()",
|
||||
)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut lock_connection = lock_pool.acquire().await.unwrap();
|
||||
sqlx::query("select pg_advisory_lock(2147483001)")
|
||||
.execute(&mut *lock_connection)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let preview_task = tokio::spawn({
|
||||
let service = service.clone();
|
||||
let workspace_id = workspace_id.clone();
|
||||
async move {
|
||||
service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: OPENAPI.as_bytes().to_vec(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let cancelled_source_id =
|
||||
wait_for_source_lifecycle_in_pool(&observer, ArtifactSourceLifecycle::Active).await;
|
||||
assert!(!preview_task.is_finished());
|
||||
preview_task.abort();
|
||||
let cancellation = preview_task.await.unwrap_err();
|
||||
assert!(cancellation.is_cancelled());
|
||||
// Dropping a sqlx query future does not synchronously cancel the backend
|
||||
// statement. Release the artificial blocker first; the abandoned
|
||||
// transaction can then finish and roll back before the detach fallback
|
||||
// obtains another pooled connection.
|
||||
sqlx::query("select pg_advisory_unlock(2147483001)")
|
||||
.execute(&mut *lock_connection)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_blocked_import_insert_to_stop(&observer).await;
|
||||
wait_for_specific_source_lifecycle(
|
||||
®istry,
|
||||
&cancelled_source_id,
|
||||
ArtifactSourceLifecycle::Detached,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from import_jobs")
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
sqlx::query("drop trigger block_openapi_import_insert on import_jobs")
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!("{OPENAPI}\n# restart-window").into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source_id = job.preview_payload["source"]["source_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
sqlx::query("delete from import_jobs where id = $1")
|
||||
.bind(job_id.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"update artifact_sources
|
||||
set created_at = now() - interval '10 minutes',
|
||||
updated_at = now() - interval '10 minutes'
|
||||
where workspace_id = $1 and source_id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(&source_id)
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry.delete_expired_import_jobs().await.unwrap();
|
||||
let recovered = registry
|
||||
.get_artifact_source(&workspace_id, &source_id.as_str().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered.lifecycle, ArtifactSourceLifecycle::Detached);
|
||||
}
|
||||
|
||||
async fn wait_for_source_lifecycle_in_pool(
|
||||
pool: &sqlx::PgPool,
|
||||
lifecycle: ArtifactSourceLifecycle,
|
||||
) -> ArtifactSourceId {
|
||||
let expected = match lifecycle {
|
||||
ArtifactSourceLifecycle::Active => "active",
|
||||
ArtifactSourceLifecycle::Detached => "detached",
|
||||
};
|
||||
for _ in 0..100 {
|
||||
let found = sqlx::query_scalar::<_, Option<String>>(
|
||||
"select min(source_id) from artifact_sources where lifecycle = $1",
|
||||
)
|
||||
.bind(expected)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(source_id) = found {
|
||||
return ArtifactSourceId::new(source_id);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("artifact source did not reach {expected}");
|
||||
}
|
||||
|
||||
async fn wait_for_blocked_import_insert_to_stop(pool: &sqlx::PgPool) {
|
||||
for _ in 0..100 {
|
||||
let active = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from pg_stat_activity
|
||||
where state = 'active'
|
||||
and query like 'insert into import_jobs%'
|
||||
)",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("cancelled import insert remained active in PostgreSQL");
|
||||
}
|
||||
|
||||
async fn wait_for_specific_source_lifecycle(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
source_id: &ArtifactSourceId,
|
||||
lifecycle: ArtifactSourceLifecycle,
|
||||
) {
|
||||
let expected = match lifecycle {
|
||||
ArtifactSourceLifecycle::Active => "active",
|
||||
ArtifactSourceLifecycle::Detached => "detached",
|
||||
};
|
||||
for _ in 0..100 {
|
||||
let found = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from artifact_sources
|
||||
where source_id = $1 and lifecycle = $2
|
||||
)",
|
||||
)
|
||||
.bind(source_id.as_str())
|
||||
.bind(expected)
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
if found {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("artifact source {source_id:?} did not reach {expected}");
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use crank_artifacts::ArtifactRef;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn public_http_contract_is_safe_for_source_failures() {
|
||||
const SOURCE_CANARY: &str = "openapi-apply-source-secret-canary";
|
||||
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("openapi_apply_source_errors");
|
||||
let app = build_test_app(registry.clone(), storage_root.clone());
|
||||
let server = spawn_admin_api(app).await;
|
||||
let client = authorized_client(&server).await;
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
for (marker, expected_code, corrupt) in [
|
||||
("missing", "openapi_upload.source_unavailable", false),
|
||||
("corrupt", "openapi_upload.source_integrity", true),
|
||||
] {
|
||||
let document = format!("{OPENAPI}\n# {marker} {SOURCE_CANARY}");
|
||||
let preview_response = client
|
||||
.post(format!("{server}/imports/openapi/preview"))
|
||||
.multipart(Form::new().part(
|
||||
"file",
|
||||
file_part(document.as_bytes(), "openapi.yaml", "application/yaml"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preview_response.status(), reqwest::StatusCode::OK);
|
||||
let preview = preview_response.json::<Value>().await.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview["job_id"].as_str().unwrap().into();
|
||||
let job = registry
|
||||
.get_import_job(&workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let artifact_ref: ArtifactRef = job.preview_payload["source"]["digest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = artifact_path(&storage_root, &artifact_ref);
|
||||
if corrupt {
|
||||
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o600))
|
||||
.unwrap();
|
||||
std::fs::write(&path, b"corrupt").unwrap();
|
||||
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o400))
|
||||
.unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(format!(
|
||||
"{server}/imports/openapi/{}/create",
|
||||
job_id.as_str()
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"selected_operation_keys": ["GET /health"],
|
||||
"conflict_mode": "skip"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
assert_eq!(status, reqwest::StatusCode::UNPROCESSABLE_ENTITY, "{body}");
|
||||
assert_eq!(body["error"]["code"], expected_code);
|
||||
assert!(body["error"]["request_id"].is_string());
|
||||
assert!(body["error"]["trace_id"].is_string());
|
||||
let rendered = body.to_string();
|
||||
assert!(!rendered.contains(SOURCE_CANARY));
|
||||
assert!(!rendered.contains("source_id"));
|
||||
assert!(!rendered.contains("digest"));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from operations where workspace_id = $1")
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0,
|
||||
"source failures must not create operations"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn missing_corrupt_changed_and_foreign_sources_create_no_drafts() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("openapi_source_fail_closed");
|
||||
let service = test_service(
|
||||
registry.clone(),
|
||||
storage_root.clone(),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
let (missing_job, _, missing_ref) =
|
||||
preview_job_source(®istry, &service, &workspace_id, "missing").await;
|
||||
std::fs::remove_file(artifact_path(&storage_root, &missing_ref)).unwrap();
|
||||
assert!(
|
||||
apply_health_operation(&service, &workspace_id, &missing_job)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let (corrupt_job, _, corrupt_ref) =
|
||||
preview_job_source(®istry, &service, &workspace_id, "corrupt").await;
|
||||
let corrupt_path = artifact_path(&storage_root, &corrupt_ref);
|
||||
std::fs::set_permissions(
|
||||
&corrupt_path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o600),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&corrupt_path, b"corrupt-openapi-canary").unwrap();
|
||||
std::fs::set_permissions(
|
||||
&corrupt_path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o400),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
apply_health_operation(&service, &workspace_id, &corrupt_job)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let (changed_job, _, _) =
|
||||
preview_job_source(®istry, &service, &workspace_id, "changed").await;
|
||||
sqlx::query(
|
||||
"update import_jobs
|
||||
set preview_payload = jsonb_set(
|
||||
preview_payload,
|
||||
'{source,digest}',
|
||||
to_jsonb($1::text)
|
||||
)
|
||||
where id = $2",
|
||||
)
|
||||
.bind(format!("sha256:{}", "0".repeat(64)))
|
||||
.bind(changed_job.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
apply_health_operation(&service, &workspace_id, &changed_job)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let foreign_workspace = WorkspaceId::new("ws_openapi_foreign");
|
||||
registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &Workspace {
|
||||
id: foreign_workspace.clone(),
|
||||
slug: "openapi-foreign".to_owned(),
|
||||
display_name: "OpenAPI Foreign".to_owned(),
|
||||
status: WorkspaceStatus::Active,
|
||||
settings: serde_json::json!({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let (foreign_job, _, _) =
|
||||
preview_job_source(®istry, &service, &workspace_id, "foreign").await;
|
||||
sqlx::query("update import_jobs set workspace_id = $1 where id = $2")
|
||||
.bind(foreign_workspace.as_str())
|
||||
.bind(foreign_job.as_str())
|
||||
.execute(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
apply_health_operation(&service, &foreign_workspace, &foreign_job)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.list_operations(&foreign_workspace)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
async fn preview_job_source(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
service: &admin_api::service::AdminService,
|
||||
workspace_id: &WorkspaceId,
|
||||
marker: &str,
|
||||
) -> (crank_registry::ImportJobId, ArtifactSourceId, ArtifactRef) {
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
workspace_id,
|
||||
OpenApiUpload {
|
||||
bytes: format!("{OPENAPI}\n# {marker}").into_bytes(),
|
||||
mime_type: "application/yaml".to_owned(),
|
||||
locale: OpenApiUploadLocale::En,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||
let job = registry
|
||||
.get_import_job(workspace_id, &job_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = &job.preview_payload["source"];
|
||||
(
|
||||
job_id,
|
||||
ArtifactSourceId::new(source["source_id"].as_str().unwrap()),
|
||||
source["digest"].as_str().unwrap().parse().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn artifact_path(root: &std::path::Path, artifact_ref: &ArtifactRef) -> std::path::PathBuf {
|
||||
root.join("sha256")
|
||||
.join(&artifact_ref.digest_hex()[..2])
|
||||
.join(artifact_ref.digest_hex())
|
||||
}
|
||||
|
||||
async fn apply_health_operation(
|
||||
service: &admin_api::service::AdminService,
|
||||
workspace_id: &WorkspaceId,
|
||||
job_id: &crank_registry::ImportJobId,
|
||||
) -> Result<admin_api::service::OpenApiImportCreateResponse, admin_api::error::ApiError> {
|
||||
service
|
||||
.create_openapi_import(
|
||||
workspace_id,
|
||||
job_id,
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /health".to_owned()],
|
||||
server_url: None,
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use super::super::common::build_test_app_with_external_references;
|
||||
use super::*;
|
||||
use std::{
|
||||
io,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use opentelemetry_sdk::{
|
||||
error::OTelSdkResult,
|
||||
trace::{SdkTracerProvider, SpanData, SpanExporter},
|
||||
};
|
||||
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn parser_canary_never_reaches_diagnostics_logs_traces_or_metrics() {
|
||||
const CANARY: &str = "openapi-telemetry-secret-canary";
|
||||
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
recorder
|
||||
.install()
|
||||
.expect("isolated integration test metrics recorder");
|
||||
|
||||
let writer = SharedLogWriter::default();
|
||||
let exported = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = SdkTracerProvider::builder()
|
||||
.with_simple_exporter(CapturingExporter(Arc::clone(&exported)))
|
||||
.build();
|
||||
let tracer = provider.tracer("admin-openapi-source-test");
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_writer(writer.clone()),
|
||||
)
|
||||
.with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
tracing::dispatcher::set_global_default(dispatch)
|
||||
.expect("isolated integration test tracing subscriber");
|
||||
|
||||
let registry = test_registry().await;
|
||||
let app = build_test_app(registry, test_storage_root("openapi_telemetry_canary"));
|
||||
let server = spawn_admin_api(app).await;
|
||||
let client = authorized_client(&server).await;
|
||||
let document = format!(
|
||||
"openapi: 3.0.3\ninfo: {{ title: Canary }}\npaths:\n /broken:\n get:\n description: {CANARY}\n responses: ["
|
||||
);
|
||||
let response = client
|
||||
.post(format!("{server}/imports/openapi/preview"))
|
||||
.multipart(Form::new().part(
|
||||
"file",
|
||||
file_part(document.as_bytes(), "openapi.yaml", "application/yaml"),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
let trace_id = response
|
||||
.headers()
|
||||
.get("x-trace-id")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(!body.contains(CANARY));
|
||||
let diagnostics: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(diagnostics["error"]["trace_id"], trace_id);
|
||||
|
||||
let route = format!("/{CANARY}.yaml");
|
||||
let external = axum::Router::new().route(
|
||||
&route,
|
||||
axum::routing::get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, CANARY) }),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, external).await.unwrap() });
|
||||
let origin = format!("http://{address}");
|
||||
let external_registry = test_registry().await;
|
||||
let external_app = build_test_app_with_external_references(
|
||||
external_registry,
|
||||
test_storage_root("openapi_materialization_telemetry_canary"),
|
||||
vec![format!("{origin}/")],
|
||||
);
|
||||
let external_server = spawn_admin_api(external_app).await;
|
||||
let external_client = authorized_client(&external_server).await;
|
||||
let external_document = format!(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: {{ title: Safe materialization }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema: {{ $ref: '{origin}/{CANARY}.yaml#/Item' }}
|
||||
"#
|
||||
);
|
||||
let response = external_client
|
||||
.post(format!("{external_server}/imports/openapi/preview"))
|
||||
.multipart(Form::new().part(
|
||||
"file",
|
||||
file_part(
|
||||
external_document.as_bytes(),
|
||||
"external.yaml",
|
||||
"application/yaml",
|
||||
),
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
provider.force_flush().unwrap();
|
||||
let logs = writer.output();
|
||||
assert!(!logs.contains(CANARY));
|
||||
assert!(logs.contains(&trace_id));
|
||||
let safe_materialization_failure = logs.lines().any(is_safe_materialization_failure);
|
||||
assert!(
|
||||
safe_materialization_failure,
|
||||
"expected one bounded, sanitized materialization failure"
|
||||
);
|
||||
let spans = exported.lock().unwrap();
|
||||
let rendered_spans = format!("{spans:?}");
|
||||
assert!(!rendered_spans.contains(CANARY));
|
||||
drop(spans);
|
||||
for (key, _, _, _) in snapshotter.snapshot().into_vec() {
|
||||
assert!(!key.key().name().contains(CANARY));
|
||||
assert!(
|
||||
!key.key()
|
||||
.labels()
|
||||
.any(|label| { label.key().contains(CANARY) || label.value().contains(CANARY) })
|
||||
);
|
||||
}
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
fn is_safe_materialization_failure(line: &str) -> bool {
|
||||
if !line.contains("external OpenAPI materialization failed") || !line.contains("count=0") {
|
||||
return false;
|
||||
}
|
||||
let immediate_status = contains_log_field(line, "stage", "fetch")
|
||||
&& contains_log_field(line, "error_code", "unexpected_status");
|
||||
let bounded_timeout = contains_log_field(line, "stage", "chain")
|
||||
&& contains_log_field(line, "error_code", "timeout");
|
||||
immediate_status || bounded_timeout
|
||||
}
|
||||
|
||||
fn contains_log_field(line: &str, name: &str, value: &str) -> bool {
|
||||
line.contains(&format!("{name}=\"{value}\"")) || line.contains(&format!("{name}={value}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_log_accepts_only_coherent_safe_outcomes() {
|
||||
for line in [
|
||||
r#"external OpenAPI materialization failed stage="fetch" error_code="unexpected_status" count=0"#,
|
||||
"external OpenAPI materialization failed stage=chain error_code=timeout count=0",
|
||||
] {
|
||||
assert!(is_safe_materialization_failure(line));
|
||||
}
|
||||
assert!(!is_safe_materialization_failure(
|
||||
r#"external OpenAPI materialization failed stage="fetch" error_code="timeout" count=0"#
|
||||
));
|
||||
}
|
||||
|
||||
#[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),
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.pending.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedLogGuard {
|
||||
fn drop(&mut self) {
|
||||
self.buffer.lock().unwrap().extend_from_slice(&self.pending);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_log_writer_publishes_complete_records_only() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let mut guard = writer.make_writer();
|
||||
io::Write::write_all(&mut guard, b"message").unwrap();
|
||||
assert!(writer.output().is_empty());
|
||||
|
||||
io::Write::write_all(&mut guard, b" stage=\"fetch\"\n").unwrap();
|
||||
drop(guard);
|
||||
assert_eq!(writer.output(), "message stage=\"fetch\"\n");
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CapturingExporter(Arc<Mutex<Vec<SpanData>>>);
|
||||
|
||||
impl SpanExporter for CapturingExporter {
|
||||
async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
|
||||
self.0.lock().unwrap().extend(batch);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
|
||||
const WORKSPACE_ID: &str = "ws_default";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn portable_yaml_v2_is_redacted_and_semantic_replay_is_a_noop() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-yaml-v2");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "portable_v2"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
|
||||
let yaml = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(yaml.contains("format_version: '2'") || yaml.contains("format_version: 2"));
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let operation = parsed["operation"].as_mapping().unwrap();
|
||||
for forbidden in ["id", "status", "created_at", "wizard_state", "samples"] {
|
||||
assert!(!operation.contains_key(serde_yaml::Value::String(forbidden.to_owned())));
|
||||
}
|
||||
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(yaml.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay["operation_id"], operation_id);
|
||||
assert_eq!(replay["version"], 1);
|
||||
|
||||
let changed_yaml = yaml.replace(
|
||||
"display_name: Create Lead",
|
||||
"display_name: Portable Changed",
|
||||
);
|
||||
assert_ne!(changed_yaml, yaml);
|
||||
let missing = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(changed_yaml.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing["error"]["code"], "operation_precondition_required");
|
||||
|
||||
let changed = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, operation_id).await,
|
||||
)
|
||||
.body(changed_yaml)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), reqwest::StatusCode::OK);
|
||||
let changed = changed.json::<Value>().await.unwrap();
|
||||
assert_eq!(changed["version"], 2);
|
||||
|
||||
let current_yaml = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let archived = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/archive"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, operation_id).await,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(archived.status().is_success());
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(current_yaml)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
|
||||
assert!(replay.text().await.unwrap().contains("operation_archived"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn hostile_and_oversized_yaml_fail_before_mutation_without_echoing_canary() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-yaml-hostile");
|
||||
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let canary = "yaml-secret-canary-never-reflect";
|
||||
let hostile = format!(
|
||||
"format_version: '2'\nkind: operation\noperation:\n name: bad\n unknown_secret: {canary}\n"
|
||||
);
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(hostile)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(!body.contains(canary));
|
||||
assert!(body.contains("operation_yaml_invalid"));
|
||||
let body: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(body["error"]["code"], "operation_yaml_invalid");
|
||||
|
||||
let oversized = "x".repeat(256 * 1024 + 1);
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(oversized)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
|
||||
let count = registry
|
||||
.list_operations(&crank_core::WorkspaceId::new(WORKSPACE_ID))
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let alias = "format_version: '2'\nkind: operation\noperation: &shared\n name: alias\n";
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(alias)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("operation_yaml_unsupported")
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(vec![0xff, 0xfe, 0xfd])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("operation_yaml_invalid")
|
||||
);
|
||||
|
||||
let mut credential_payload = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"portable_credential_header",
|
||||
))
|
||||
.unwrap();
|
||||
credential_payload["target"]["static_headers"]["X-Auth-Token"] =
|
||||
Value::String("credential-canary".to_owned());
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&credential_payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let credential_operation_id = created["operation_id"].as_str().unwrap();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{base_url}/operations/{credential_operation_id}/export"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(!response.text().await.unwrap().contains("credential-canary"));
|
||||
|
||||
let mut portable = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"unknown_nested_yaml",
|
||||
))
|
||||
.unwrap();
|
||||
portable.as_object_mut().unwrap().remove("wizard_state");
|
||||
portable["target"]["unknown_nested"] = Value::String(canary.to_owned());
|
||||
let document = serde_yaml::to_string(&serde_json::json!({
|
||||
"format_version": "2",
|
||||
"kind": "operation",
|
||||
"operation": portable
|
||||
}))
|
||||
.unwrap();
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(document)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(!response.text().await.unwrap().contains(canary));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn operation_mutations_require_identity_bound_etags_and_versions_are_stable() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-etag");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let first = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_first"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let second = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_second"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let first_id = first["operation_id"].as_str().unwrap();
|
||||
let second_id = second["operation_id"].as_str().unwrap();
|
||||
|
||||
let missing = client
|
||||
.post(format!("{base_url}/operations/{first_id}/archive"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing["error"]["code"], "operation_precondition_required");
|
||||
|
||||
let first_etag = operation_etag(&client, &base_url, first_id).await;
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/{second_id}/archive"))
|
||||
.header(reqwest::header::IF_MATCH, first_etag)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
|
||||
let replay = replay.json::<Value>().await.unwrap();
|
||||
assert_eq!(replay["error"]["code"], "operation_stale_version");
|
||||
|
||||
let v1 = client
|
||||
.get(format!("{base_url}/operations/{first_id}/versions/1"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v1.status(), reqwest::StatusCode::OK);
|
||||
let v1_etag = v1.headers()[reqwest::header::ETAG]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut update =
|
||||
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_first")).unwrap();
|
||||
update["display_name"] = Value::String("Changed Draft".to_owned());
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/operations/{first_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, first_id).await,
|
||||
)
|
||||
.json(&update)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let v1_after = client
|
||||
.get(format!("{base_url}/operations/{first_id}/versions/1"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v1_after.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
v1_after.headers()[reqwest::header::ETAG].to_str().unwrap(),
|
||||
v1_etag
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn same_etag_concurrent_patches_have_exactly_one_winner() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-etag-race");
|
||||
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_race"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
let etag = operation_etag(&client, &base_url, operation_id).await;
|
||||
let mut first =
|
||||
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_race")).unwrap();
|
||||
first["display_name"] = Value::String("first winner candidate".to_owned());
|
||||
let mut second = first.clone();
|
||||
second["display_name"] = Value::String("second winner candidate".to_owned());
|
||||
let url = format!("{base_url}/operations/{operation_id}");
|
||||
let first_request = client
|
||||
.patch(&url)
|
||||
.header(reqwest::header::IF_MATCH, &etag)
|
||||
.json(&first)
|
||||
.send();
|
||||
let second_request = client
|
||||
.patch(&url)
|
||||
.header(reqwest::header::IF_MATCH, &etag)
|
||||
.json(&second)
|
||||
.send();
|
||||
let (first_response, second_response) = tokio::join!(first_request, second_request);
|
||||
let statuses = [
|
||||
first_response.unwrap().status(),
|
||||
second_response.unwrap().status(),
|
||||
];
|
||||
assert_eq!(
|
||||
statuses.iter().filter(|status| status.is_success()).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
statuses
|
||||
.iter()
|
||||
.filter(|status| **status == reqwest::StatusCode::CONFLICT)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let summary = registry
|
||||
.get_operation_summary(
|
||||
&crank_core::WorkspaceId::new(WORKSPACE_ID),
|
||||
&crank_core::OperationId::new(operation_id),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(summary.current_draft_version, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn operation_rejects_unscoped_auth_profile_reference_before_save() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-auth-reference");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let mut payload = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"missing_auth_profile",
|
||||
))
|
||||
.unwrap();
|
||||
payload["execution_config"]["auth_profile_ref"] =
|
||||
Value::String("auth_foreign_or_missing".to_owned());
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(body.contains("operation_auth_profile_invalid"));
|
||||
assert!(!body.contains("auth_foreign_or_missing"));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user