diff --git a/.env.example b/.env.example index a7650d5..41786a8 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,34 @@ -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_RESPONSE_BYTES=4194304 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 +41,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_TRUST_FORWARDED_HEADERS=false +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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index bdfa5de..79fa57c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -61,6 +61,34 @@ 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 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 +108,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 @@ -199,8 +227,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: | diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index beaef31..b42eaa2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -41,6 +41,7 @@ jobs: - name: Verify runner toolchain run: | + python3 --version rustc --version cargo --version node --version @@ -52,11 +53,46 @@ 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 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 @@ -82,15 +118,19 @@ jobs: working-directory: apps/ui run: 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: 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 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 + 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 \ @@ -130,6 +170,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 < 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,11 +7,12 @@ 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 @@ -60,6 +23,7 @@ RUN apt-get update \ WORKDIR /app COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api +COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate ENV CRANK_ADMIN_BIND=0.0.0.0:3001 diff --git a/apps/admin-api/src/bin/crank-migrate.rs b/apps/admin-api/src/bin/crank-migrate.rs new file mode 100644 index 0000000..1e4eaaa --- /dev/null +++ b/apps/admin-api/src/bin/crank-migrate.rs @@ -0,0 +1,242 @@ +use std::{process::ExitCode, time::Duration}; + +use crank_config::{ConfigSource, DatabaseSettings, parse_migrator}; +use crank_registry::{ + BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight, +}; +use serde_json::json; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; + +#[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, +} + +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(), + } + } +} + +async fn run() -> Result { + let mut arguments = std::env::args().skip(1); + let requested = arguments.next(); + let option = arguments.next(); + if arguments.next().is_some() { + return Err(CliError::new( + "invalid_command", + "cli.arguments", + "run_preflight", + )); + } + let command = match requested.as_deref() { + 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::>(); + let plan = json!({ "schema_version": 1, "sequence": sequence }); + if option.as_deref() == 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 = 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 connect(config: &DatabaseSettings) -> Result { + let options = if let Some(url) = &config.url { + url.expose_secret() + .parse::() + .map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))? + } else { + PgConnectOptions::new() + .host(&config.host) + .port(config.port) + .database(&config.database) + .username(&config.username) + .password(config.password.expose_secret()) + }; + 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", + )) +} diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index 60fe641..9302ab5 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -551,6 +551,7 @@ 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, diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index cf2d0d4..3acb9f7 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -74,9 +74,9 @@ impl ApiError { } } - pub fn internal(message: impl Into) -> Self { + pub fn internal(_message: impl Into) -> Self { Self::Internal { - message: message.into(), + message: "internal server error".to_owned(), context: None, } } @@ -165,6 +165,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 @@ -363,9 +370,10 @@ impl From 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::Migration(_) + | RegistryError::Storage(_) + | RegistryError::Serialization(_) + | RegistryError::InvalidCorrelationIdentity { .. } => Self::internal(value.to_string()), } } } @@ -399,7 +407,7 @@ impl From for ApiError { pub fn runtime_test_failure(error: &RuntimeError) -> Value { let mut payload = json!({ "code": runtime_test_failure_code(error), - "message": error.to_string() + "message": safe_runtime_test_failure_message(error) }); if let Some(context) = runtime_error_context(error) { payload["context"] = context; @@ -407,6 +415,33 @@ pub fn runtime_test_failure(error: &RuntimeError) -> Value { payload } +fn safe_runtime_test_failure_message(error: &RuntimeError) -> &'static str { + match error { + RuntimeError::Schema(_) => "input schema validation failed", + RuntimeError::Mapping(_) => "input mapping failed", + RuntimeError::RestAdapter(_) | RuntimeError::ProtocolAdapter(_) => { + "upstream execution failed" + } + RuntimeError::UnsupportedProtocol { .. } => "operation protocol is unsupported", + RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime concurrency limit exceeded", + RuntimeError::InvalidPreparedRequest { .. } => "prepared request is invalid", + RuntimeError::ConfirmationRequired { .. } => "operation confirmation is required", + RuntimeError::InvalidConfirmationToken { .. } => "confirmation token is invalid", + RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation store is unavailable", + RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency store is unavailable", + RuntimeError::IdempotencyInProgress { .. } => "idempotent execution is in progress", + RuntimeError::IdempotencyConflict { .. } => "idempotency key conflicts with the request", + RuntimeError::IdempotencyOutcomeUnknown { .. } => "previous execution outcome is unknown", + RuntimeError::UnsupportedExecutionMode { .. } => "execution mode is unsupported", + RuntimeError::MissingAuthProfile { .. } => "authorization profile is missing", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "authorization secret is missing" + } + RuntimeError::InvalidAuthSecretValue { .. } => "authorization secret is invalid", + RuntimeError::SecretCrypto { .. } => "authorization secret processing failed", + } +} + fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "runtime_schema_error", @@ -435,9 +470,8 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { pub fn runtime_error_context(error: &RuntimeError) -> Option { match error { - RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ + RuntimeError::InvalidPreparedRequest { field, .. } => Some(json!({ "field": field, - "reason": reason, })), RuntimeError::ConfirmationRequired { confirmation_token, @@ -457,14 +491,10 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option { | RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({ "operation_id": operation_id, })), - RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({ + RuntimeError::InvalidAuthSecretValue { secret_id, .. } => Some(json!({ "secret_id": secret_id, - "reason": reason, - })), - RuntimeError::SecretCrypto { operation, details } => Some(json!({ - "operation": operation, - "details": details, })), + RuntimeError::SecretCrypto { .. } => None, RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({ "auth_profile_id": auth_profile_id, })), diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 3dfc097..86f0886 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, net::SocketAddr, path::PathBuf, time::Duration}; +use std::{io, net::SocketAddr, process::ExitCode, time::Duration}; use admin_api::{ app::build_app, @@ -8,9 +8,14 @@ use admin_api::{ 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::{PostgresPoolConfig, PostgresRegistry}; use crank_runtime::{ @@ -21,17 +26,77 @@ 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> { - 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::() { + return config.to_json(); + } + if let Some(migration) = cause.downcast_ref::() { + 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::() + { + return serde_json::json!({ + "status": "error", + "code": migration.code(), + "stage": migration.stage(), + "version": migration.version(), + "recovery": migration.recovery(), + }) + .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> { + 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 +108,67 @@ async fn main() -> Result<(), Box> { } async fn run( + config: AdminProcessConfig, observability: &ObservabilityLifecycle, startup_completed: &mut bool, ) -> Result<(), Box> { - 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::().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.expose_secret().to_owned(), + 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 = SecretCrypto::new(config.runtime.master_key.expose_secret())?; + let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new( + config.runtime.outbound.allowed_hosts.clone(), + config.runtime.outbound.denied_hosts.clone(), + 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()) @@ -100,7 +178,7 @@ async fn run( PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone()); let service = AdminServiceBuilder::new( registry, - storage_root, + config.storage_root.clone(), auth_settings, secret_crypto, runtime, @@ -108,12 +186,11 @@ async fn run( .with_outbound_http_policy(outbound_http_policy) .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_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 +198,10 @@ async fn run( } else { RequestRateLimiter::new(api_rate_limit) }, - trust_forwarded_headers: env_flag("CRANK_TRUST_FORWARDED_HEADERS"), + trust_forwarded_headers: config.trust_forwarded_headers, }; 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::(); info!( @@ -138,14 +215,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 +229,163 @@ async fn run( } else { axum::serve(listener, make_service).await?; } - Ok(()) } -fn invocation_log_retention_days_from_env() -> Result> { - const NAME: &str = "CRANK_INVOCATION_LOG_RETENTION_DAYS"; - let value = match env::var(NAME) { - Ok(raw) => raw.parse::()?, - Err(env::VarError::NotPresent) => 30, - Err(error) => return Err(error.into()), - }; - if !(1..=MAX_INVOCATION_LOG_RETENTION_DAYS).contains(&value) { - return Err( - format!("{NAME} must be between 1 and {MAX_INVOCATION_LOG_RETENTION_DAYS}").into(), - ); +fn init_observability( + config: &ObservabilitySettings, +) -> Result> { + 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( + config.runtime.outbound.allowed_hosts.clone(), + config.runtime.outbound.denied_hosts.clone(), + 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(()) +} + +fn otlp_config( + config: &ObservabilitySettings, +) -> Result { + 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::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> { + if let Some(url) = &config.url { + return url + .expose_secret() + .parse::() + .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::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) { @@ -197,50 +410,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> { - let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(30); - let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(60); - - Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) -} - -fn database_options_from_env() -> Result> { - if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { - return Ok(database_url.parse::()?); - } - - let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into()); - let port = env::var("POSTGRES_PORT") - .ok() - .and_then(|value| value.parse::().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)) -} diff --git a/apps/admin-api/src/request_context.rs b/apps/admin-api/src/request_context.rs index d55e620..5ff0e52 100644 --- a/apps/admin-api/src/request_context.rs +++ b/apps/admin-api/src/request_context.rs @@ -4,20 +4,30 @@ 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_observability::{set_remote_trace_parent, 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 +37,105 @@ 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); + } + response + }, + ) .await } +fn resolve_correlation(headers: &axum::http::HeaderMap) -> (RequestId, Option) { + 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) { + let mut headers = axum::http::HeaderMap::new(); + if let Ok(value) = HeaderValue::from_str(context.traceparent()) { + headers.insert("traceparent", value); + set_remote_trace_parent(span, &headers); + } +} + #[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")); } } diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 8c5e8f9..25debbe 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -193,7 +193,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))) diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index d712690..e79ed81 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -469,6 +469,7 @@ impl AdminService { 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, @@ -506,6 +507,7 @@ impl AdminService { observe_invocation_history_outcome( outcome, request.request_id, + request.trace_id, request.status, request.source, ); @@ -516,6 +518,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 +531,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(), @@ -935,6 +939,7 @@ mod tests { category: InvocationHistoryLossCategory::InvalidRecord, }), Some("req_admin_dc08"), + Some("0af7651916cd43dd8448eb211c80319c"), InvocationStatus::Error, InvocationSource::AgentToolCall, ); diff --git a/apps/admin-api/src/service/demo.rs b/apps/admin-api/src/service/demo.rs index 9f151ce..37d3852 100644 --- a/apps/admin-api/src/service/demo.rs +++ b/apps/admin-api/src/service/demo.rs @@ -316,11 +316,13 @@ 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, diff --git a/apps/admin-api/src/service/operations.rs b/apps/admin-api/src/service/operations.rs index c7f5f5a..c1bf878 100644 --- a/apps/admin-api/src/service/operations.rs +++ b/apps/admin-api/src/service/operations.rs @@ -444,9 +444,11 @@ impl AdminService { workspace_id: &WorkspaceId, operation_id: &OperationId, payload: TestRunPayload, - request_id: &str, + correlation: &crank_core::CorrelationContext, ) -> Result { - 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 runtime_request_context = RuntimeRequestContext::from_correlation(correlation) .with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun); let record = self .get_operation_version(workspace_id, operation_id, payload.version) @@ -467,6 +469,7 @@ 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, @@ -516,6 +519,7 @@ 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, @@ -543,6 +547,7 @@ 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, diff --git a/apps/admin-api/tests/config_startup.rs b/apps/admin-api/tests/config_startup.rs new file mode 100644 index 0000000..a248c26 --- /dev/null +++ b/apps/admin-api/tests/config_startup.rs @@ -0,0 +1,71 @@ +use std::process::Command; + +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", "master"), + ("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); +} diff --git a/apps/admin-api/tests/integration.rs b/apps/admin-api/tests/integration.rs index f58bd88..bbdb103 100644 --- a/apps/admin-api/tests/integration.rs +++ b/apps/admin-api/tests/integration.rs @@ -4,5 +4,6 @@ mod integration { mod community_access_usage; mod openapi_import; mod operations_agents; + mod request_context; mod secrets_import_auth; } diff --git a/apps/admin-api/tests/integration/common.rs b/apps/admin-api/tests/integration/common.rs index 52a258e..e0a8f3b 100644 --- a/apps/admin-api/tests/integration/common.rs +++ b/apps/admin-api/tests/integration/common.rs @@ -211,6 +211,10 @@ pub(super) async fn create_lead(Json(payload): Json) -> Json { 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 password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap(); let user_id = registry diff --git a/apps/admin-api/tests/integration/operations_agents.rs b/apps/admin-api/tests/integration/operations_agents.rs index 01b0629..f132250 100644 --- a/apps/admin-api/tests/integration/operations_agents.rs +++ b/apps/admin-api/tests/integration/operations_agents.rs @@ -10,7 +10,7 @@ use std::{ }; use async_trait::async_trait; -use axum::{Json, Router, routing::post}; +use axum::{Json, Router, extract::State, http::HeaderMap, routing::post}; use crank_core::{ ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, @@ -97,8 +97,8 @@ impl IdentityProvider for RejectingIdentityProvider { async fn creates_publishes_and_tests_rest_operation() { let registry = test_registry().await; let storage_root = test_storage_root("lifecycle"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let (upstream_base_url, observed_upstream_headers) = spawn_correlation_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; let client = authorized_client(&base_url).await; let created = client @@ -132,18 +132,25 @@ async fn creates_publishes_and_tests_rest_operation() { .json::() .await .unwrap(); - let test_run = client + let test_run_response = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .header("x-request-id", "req_admin_test_run") + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ) .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .send() .await - .unwrap() - .json::() - .await .unwrap(); + assert_eq!( + test_run_response.headers()["x-trace-id"].to_str().unwrap(), + "0af7651916cd43dd8448eb211c80319c" + ); + let test_run = test_run_response.json::().await.unwrap(); assert_eq!(listed["items"][0]["name"], "crm_create_lead"); assert_eq!( @@ -158,6 +165,65 @@ async fn creates_publishes_and_tests_rest_operation() { "user@example.com" ); assert_eq!(test_run["response_preview"]["id"], "lead_123"); + let logs = registry + .list_invocation_logs(crank_registry::ListInvocationLogsQuery { + workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID), + level: None, + search_text: None, + source: Some(crank_core::InvocationSource::AdminTestRun), + operation_id: Some(&crank_core::OperationId::new(&operation_id)), + agent_id: None, + created_after: None, + limit: 10, + }) + .await + .unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!( + logs[0].log.request_id.as_deref(), + Some("req_admin_test_run") + ); + assert_eq!( + logs[0].log.trace_id.as_deref(), + Some("0af7651916cd43dd8448eb211c80319c") + ); + let upstream_headers = observed_upstream_headers.lock().await; + let upstream_headers = upstream_headers.as_ref().unwrap(); + assert_eq!( + upstream_headers["x-request-id"].to_str().unwrap(), + "req_admin_test_run" + ); + assert_eq!( + &upstream_headers["traceparent"].to_str().unwrap()[3..35], + "0af7651916cd43dd8448eb211c80319c" + ); +} + +async fn spawn_correlation_upstream_server() -> (String, Arc>>) +{ + let observed = Arc::new(tokio::sync::Mutex::new(None)); + let app = Router::new() + .route("/crm/leads", post(capture_correlation_and_create_lead)) + .with_state(Arc::clone(&observed)); + 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) +} + +async fn capture_correlation_and_create_lead( + State(observed): State>>>, + headers: HeaderMap, + Json(payload): Json, +) -> Json { + *observed.lock().await = Some(headers); + Json(json!({ + "id": "lead_123", + "status": "created", + "input": payload, + })) } #[tokio::test(flavor = "multi_thread")] diff --git a/apps/admin-api/tests/integration/request_context.rs b/apps/admin-api/tests/integration/request_context.rs index fa0fe44..e002e15 100644 --- a/apps/admin-api/tests/integration/request_context.rs +++ b/apps/admin-api/tests/integration/request_context.rs @@ -3,10 +3,10 @@ use std::{ sync::{Arc, Mutex}, }; -use admin_api::request_context::{REQUEST_ID_HEADER, apply_request_context}; +use admin_api::request_context::{REQUEST_ID_HEADER, TRACE_ID_HEADER, apply_request_context}; use axum::{ Router, - body::Body, + body::{Body, to_bytes}, http::{HeaderMap, HeaderValue, Request, StatusCode}, routing::get, }; @@ -64,6 +64,11 @@ async fn logs_request_completion_and_rejects_untrusted_values() { .find(|event: &serde_json::Value| event["event"] == "admin.request.completed") .unwrap(); assert_eq!(event["request_id"], "req_admin_trace_123"); + let trace_id = response.headers()[TRACE_ID_HEADER.as_str()] + .to_str() + .unwrap(); + assert_eq!(trace_id.len(), 32); + assert_eq!(event["trace_id"], trace_id); assert_eq!(event["fields"]["status"], 200); assert_eq!(event["fields"]["route"], "/probe"); @@ -85,9 +90,144 @@ async fn logs_request_completion_and_rejects_untrusted_values() { uuid::Uuid::parse_str(generated).unwrap().get_version(), Some(Version::SortRand) ); + let generated_trace = invalid_response.headers()[TRACE_ID_HEADER.as_str()] + .to_str() + .unwrap(); + assert_eq!(generated_trace.len(), 32); + assert_ne!(generated_trace, "canary-invalid-traceparent"); assert!(!writer.output().contains("canary-invalid-traceparent")); } +#[tokio::test(flavor = "current_thread")] +async fn structured_boundary_error_carries_the_same_safe_ids() { + let _tracing_test_guard = TRACING_TEST_LOCK.lock().await; + let dispatch = tracing::Dispatch::new(tracing_subscriber::registry()); + let _dispatch_guard = tracing::dispatcher::set_default(&dispatch); + let response = error_probe_app() + .oneshot( + Request::builder() + .uri("/error") + .header("x-request-id", "request-boundary-error") + .header( + "traceparent", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers()[REQUEST_ID_HEADER], + "request-boundary-error" + ); + assert_eq!( + response.headers()[TRACE_ID_HEADER], + "0af7651916cd43dd8448eb211c80319c" + ); + let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["error"]["request_id"], "request-boundary-error"); + assert_eq!( + payload["error"]["trace_id"], + "0af7651916cd43dd8448eb211c80319c" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn boundary_status_matrix_keeps_ids_and_redacts_internal_causes() { + let _tracing_test_guard = TRACING_TEST_LOCK.lock().await; + let writer = SharedLogWriter::default(); + let subscriber = crank_observability::build_subscriber( + ObservabilityConfig::new( + ServiceIdentity::try_new("admin-api", "test", "test").unwrap(), + "info", + RedactionLimits::default(), + ), + writer.clone(), + ) + .unwrap(); + let app = Router::new() + .route( + "/bad-request", + get(|| async { Err::<(), _>(admin_api::error::ApiError::validation("invalid")) }), + ) + .route( + "/unauthorized", + get(|| async { + Err::<(), _>(admin_api::error::ApiError::unauthorized("unauthorized")) + }), + ) + .route( + "/forbidden", + get(|| async { Err::<(), _>(admin_api::error::ApiError::forbidden("forbidden")) }), + ) + .route( + "/internal", + get(|| async { + Err::<(), _>(admin_api::error::ApiError::internal( + "postgres://canary-user:canary-password@private-host/database", + )) + }), + ) + .route( + "/rate-limited", + get(|| async { StatusCode::TOO_MANY_REQUESTS }), + ) + .layer(axum::middleware::from_fn(apply_request_context)); + + let responses = async { + let mut responses = Vec::new(); + for (path, status) in [ + ("/bad-request", StatusCode::BAD_REQUEST), + ("/unauthorized", StatusCode::UNAUTHORIZED), + ("/forbidden", StatusCode::FORBIDDEN), + ("/missing", StatusCode::NOT_FOUND), + ("/rate-limited", StatusCode::TOO_MANY_REQUESTS), + ("/internal", StatusCode::INTERNAL_SERVER_ERROR), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(path) + .header(REQUEST_ID_HEADER.as_str(), "matrix-request-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status); + assert_eq!(response.headers()[REQUEST_ID_HEADER], "matrix-request-id"); + assert_eq!(response.headers()[TRACE_ID_HEADER].as_bytes().len(), 32); + responses.push(to_bytes(response.into_body(), 16 * 1024).await.unwrap()); + } + responses + }; + let dispatch = tracing::Dispatch::new(subscriber); + let _dispatch_guard = tracing::dispatcher::set_default(&dispatch); + let responses = responses.await; + let combined = responses + .iter() + .flat_map(|body| body.iter().copied()) + .collect::>(); + assert!( + !combined + .windows("canary-password".len()) + .any(|value| value == b"canary-password") + ); + assert!(!writer.output().contains("canary-password")); + let internal_event: serde_json::Value = writer + .output() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .find(|event: &serde_json::Value| event["event"] == "admin.response.internal_error") + .unwrap(); + assert_eq!(internal_event["request_id"], "matrix-request-id"); + assert_eq!(internal_event["trace_id"].as_str().unwrap().len(), 32); +} + #[tokio::test(flavor = "current_thread")] async fn covers_valid_invalid_and_absent_traceparent() { let _tracing_test_guard = TRACING_TEST_LOCK.lock().await; @@ -163,6 +303,14 @@ async fn replaces_multiple_request_id_headers_with_one_uuid_v7() { REQUEST_ID_HEADER, HeaderValue::from_static("second-request-id"), ); + request.headers_mut().append( + "traceparent", + HeaderValue::from_static("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + ); + request.headers_mut().append( + "traceparent", + HeaderValue::from_static("00-1af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + ); let response = probe_app().oneshot(request).await.unwrap(); let generated = response.headers()[REQUEST_ID_HEADER].to_str().unwrap(); @@ -173,6 +321,9 @@ async fn replaces_multiple_request_id_headers_with_one_uuid_v7() { uuid::Uuid::parse_str(generated).unwrap().get_version(), Some(Version::SortRand) ); + let trace_id = response.headers()[TRACE_ID_HEADER].to_str().unwrap(); + assert_ne!(trace_id, "0af7651916cd43dd8448eb211c80319c"); + assert_ne!(trace_id, "1af7651916cd43dd8448eb211c80319c"); } fn probe_app() -> Router { @@ -181,6 +332,15 @@ fn probe_app() -> Router { .layer(axum::middleware::from_fn(apply_request_context)) } +fn error_probe_app() -> Router { + Router::new() + .route( + "/error", + get(|| async { Err::<(), _>(admin_api::error::ApiError::validation("invalid")) }), + ) + .layer(axum::middleware::from_fn(apply_request_context)) +} + fn trace_probe_app() -> Router { Router::new() .route("/trace", get(observed_traceparent)) diff --git a/apps/admin-api/tests/migration_command.rs b/apps/admin-api/tests/migration_command.rs new file mode 100644 index 0000000..e6fd9a3 --- /dev/null +++ b/apps/admin-api/tests/migration_command.rs @@ -0,0 +1,90 @@ +use std::process::{Command, Output}; + +fn command(arguments: &[&str], database_url: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_crank-migrate")); + command.args(arguments); + command.current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")); + for (name, _) in std::env::vars() { + if name.starts_with("CRANK_") || name.starts_with("POSTGRES_") || name.starts_with("OTEL_") + { + command.env_remove(name); + } + } + if let Some(database_url) = database_url { + command.env("CRANK_DATABASE_URL", database_url); + } + command.output().expect("migration command must run") +} + +#[test] +fn plan_is_deterministic_and_committed_contract_is_current() { + let first = command(&["plan"], None); + let second = command(&["plan"], None); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + assert_eq!(first.stdout, second.stdout); + let plan: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(plan["sequence"].as_array().unwrap().len(), 3); + + let checked = command(&["plan", "--check"], None); + assert!( + checked.status.success(), + "{}", + String::from_utf8_lossy(&checked.stderr) + ); +} + +#[test] +fn invalid_command_is_bounded_and_does_not_echo_arguments() { + let canary = "secret-command-canary"; + let output = command(&[canary], None); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.len() < 1_024); + assert!(!stderr.contains(canary)); + let diagnostic: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap(); + assert_eq!(diagnostic["code"], "invalid_command"); +} + +#[tokio::test] +async fn database_only_config_can_apply_and_preflight_a_fresh_schema() { + let database_url = crank_test_support::postgres_schema_url("test_migration_command").await; + let applied = command(&["apply"], Some(&database_url)); + assert!( + applied.status.success(), + "{}", + String::from_utf8_lossy(&applied.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&applied.stdout).unwrap(); + assert_eq!(result["status"], "applied"); + + let preflight = command(&["preflight"], Some(&database_url)); + assert!( + preflight.status.success(), + "{}", + String::from_utf8_lossy(&preflight.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap(); + assert_eq!(result["status"], "current"); + assert_eq!(result["version"], 3); +} + +#[tokio::test] +async fn migration_error_json_preserves_affected_version() { + let database_url = crank_test_support::postgres_schema_url("test_migration_cli_version").await; + assert!(command(&["apply"], Some(&database_url)).status.success()); + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + sqlx::query("update __crank_migrations set checksum = 'tampered' where version = 2") + .execute(&pool) + .await + .unwrap(); + + let output = command(&["preflight"], Some(&database_url)); + assert!(!output.status.success()); + let diagnostic: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(diagnostic["code"], "checksum_mismatch"); + assert_eq!(diagnostic["version"], 2); +} diff --git a/apps/admin-api/tests/unit/error.rs b/apps/admin-api/tests/unit/error.rs index 1ab2aba..bfe8a51 100644 --- a/apps/admin-api/tests/unit/error.rs +++ b/apps/admin-api/tests/unit/error.rs @@ -13,27 +13,19 @@ fn runtime_test_failure_includes_structured_context() { assert_eq!( payload["context"], json!({ - "field": "request.headers", - "reason": "must be an object" + "field": "request.headers" }) ); } #[test] -fn runtime_error_context_includes_secret_crypto_operation() { +fn runtime_error_context_does_not_expose_secret_crypto_details() { let context = runtime_error_context(&RuntimeError::SecretCrypto { operation: "decode secret envelope", details: "bad base64".to_owned(), - }) - .unwrap(); + }); - assert_eq!( - context, - json!({ - "operation": "decode secret envelope", - "details": "bad base64" - }) - ); + assert_eq!(context, None); } #[test] diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml index 55e5d3b..c019a25 100644 --- a/apps/mcp-server/Cargo.toml +++ b/apps/mcp-server/Cargo.toml @@ -15,6 +15,7 @@ async-trait = "0.1" axum.workspace = true base64.workspace = true crank-community-mcp = { path = "../../crates/crank-community-mcp" } +crank-config = { path = "../../crates/crank-config" } crank-core = { path = "../../crates/crank-core" } crank-observability = { path = "../../crates/crank-observability" } crank-registry = { path = "../../crates/crank-registry" } diff --git a/apps/mcp-server/Dockerfile b/apps/mcp-server/Dockerfile index 19d95ab..6afdfc9 100644 --- a/apps/mcp-server/Dockerfile +++ b/apps/mcp-server/Dockerfile @@ -31,9 +31,9 @@ RUN mkdir -p \ && 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 \ +RUN --mount=type=cache,id=crank-mcp-cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=crank-mcp-cargo-git,target=/usr/local/cargo/git/db \ + --mount=type=cache,id=crank-mcp-target,target=/app/target \ SQLX_OFFLINE=true cargo build --release -p mcp-server FROM rust:1.96.1-bookworm AS builder @@ -45,9 +45,9 @@ 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-mcp-cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=crank-mcp-cargo-git,target=/usr/local/cargo/git/db \ + --mount=type=cache,id=crank-mcp-target,target=/app/target \ SQLX_OFFLINE=true cargo build --release -p mcp-server \ && cp /app/target/release/mcp-server /tmp/mcp-server diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index f3ffb61..366b3a4 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -1,12 +1,17 @@ -use std::{env, net::SocketAddr, time::Duration}; +use std::{io, process::ExitCode, time::Duration}; use crank_community_mcp::{ auth::CommunityMachineCredentialVerifier, build_app_with_background_workers_and_limits, session::PostgresTransportSessionStore, }; +use crank_config::{ + CacheBackend as ConfigCacheBackend, ConfigSource, DatabaseSettings, DiagnosticCode, + McpProcessConfig, 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::{PostgresPoolConfig, PostgresRegistry}; use crank_runtime::{ @@ -19,14 +24,76 @@ use tokio::net::TcpListener; use tracing::info; #[tokio::main] -async fn main() -> Result<(), Box> { - let observability = crank_observability::init(ObservabilityConfig::from_env( - "mcp-server", - env!("CARGO_PKG_VERSION"), - "mcp_server=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::() { + return config.to_json(); + } + if let Some(migration) = cause.downcast_ref::() { + 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::() + { + return serde_json::json!({ + "status": "error", + "code": migration.code(), + "stage": migration.stage(), + "version": migration.version(), + "recovery": migration.recovery(), + }) + .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> { + let effective = parse_process(ProcessKind::McpServer, ConfigSource::from_os()?)?; + let config = effective + .mcp() + .cloned() + .ok_or_else(|| io::Error::other("MCP configuration projection is unavailable"))?; + preflight_config(&config)?; + let observability = init_observability(&config.observability)?; + for deprecation in effective.deprecations() { + tracing::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 @@ -38,49 +105,61 @@ async fn main() -> Result<(), Box> { } async fn run( + config: McpProcessConfig, observability: &ObservabilityLifecycle, startup_completed: &mut bool, ) -> Result<(), Box> { - let metrics_config = - MetricsConfig::from_env("CRANK_MCP_METRICS_BIND", "127.0.0.1:9465".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 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 = RequestRateLimitConfig::new( + config.rate_limit.requests_per_second, + config.rate_limit.burst, + )?; + 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 bind_addr = env::var("CRANK_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into()); - let base_url = env::var("CRANK_BASE_URL").ok(); - let refresh_interval = env::var("CRANK_MCP_REFRESH_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or_else(|| Duration::from_secs(5)); - let socket_addr: SocketAddr = bind_addr.parse()?; - let pool_config = PostgresPoolConfig::from_env()?; - let runtime_limits = RuntimeLimits::from_env()?; - let cache_config = RuntimeCacheConfig::from_env()?; - let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?; - let api_rate_limit = mcp_api_rate_limit_config_from_env()?; - let database_options = database_options_from_env()?; - let registry = - PostgresRegistry::connect_with_options_and_pool_config(database_options, pool_config) - .await?; if metrics_enabled { spawn_postgres_pool_metrics(registry.pool().clone()); } let session_store = PostgresTransportSessionStore::from_pool(registry.pool().clone()).await?; - let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; - let runtime = crank_runtime::community_from_env()? + let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?; + let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new( + config.runtime.outbound.allowed_hosts.clone(), + config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_response_bytes, + )?; + let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy) .with_limits(runtime_limits) .with_response_cache(cache_stores.response.clone()) .with_coordination_store(cache_stores.coordination.clone()) .build(); let app = build_app_with_background_workers_and_limits( registry, - refresh_interval, - base_url, + Duration::from_millis(config.refresh_ms), + config.runtime.base_url.clone(), secret_crypto, runtime, if cache_config.backend.is_external() { @@ -93,7 +172,7 @@ async fn run( std::sync::Arc::new(CommunityMachineCredentialVerifier), runtime_limits.max_concurrent_sessions, ); - let listener = TcpListener::bind(socket_addr).await?; + let listener = TcpListener::bind(config.bind_addr).await?; info!( name: "mcp.postgres_pool.configured", @@ -109,11 +188,7 @@ async fn run( max_lifetime_ms = pool_config.max_lifetime_ms, "postgres pool configured" ); - info!( - name: "mcp.server.listening", - bind_address = %socket_addr, - "mcp-server listening" - ); + info!(name: "mcp.server.listening", bind_address = %config.bind_addr, "mcp-server listening"); *startup_completed = true; if let Some(metrics_server) = metrics_server { @@ -124,42 +199,176 @@ async fn run( } else { axum::serve(listener, app).await?; } - Ok(()) } -fn database_options_from_env() -> Result> { - if let Ok(database_url) = env::var("CRANK_DATABASE_URL") { - return Ok(database_url.parse::()?); +fn init_observability( + config: &ObservabilitySettings, +) -> Result> { + let identity = ServiceIdentity::try_new( + "mcp-server", + 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 values = &config.otlp; + let 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(), + )?; + Ok(ObservabilityLifecycle::init_with_exporters( + base, sentry, otlp, + )?) +} + +fn preflight_config(config: &McpProcessConfig) -> 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("mcp.rate_limit"))?; + SecretCrypto::new(config.runtime.master_key.expose_secret()) + .map_err(|_| invalid("runtime.master_key"))?; + crank_runtime::OutboundHttpPolicy::try_new( + config.runtime.outbound.allowed_hosts.clone(), + config.runtime.outbound.denied_hosts.clone(), + config.runtime.outbound.max_response_bytes, + ) + .map_err(|_| invalid("runtime.outbound"))?; + let identity = ServiceIdentity::try_new( + "mcp-server", + 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"))?; + let values = &config.observability.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(|v| v.expose_secret().to_owned()), + values + .traces_headers + .as_ref() + .map(|v| v.expose_secret().to_owned()), + values.max_queue_size, + values.max_export_batch_size, + values.schedule_delay.clone(), + values.export_timeout.clone(), + ) + .map_err(|_| invalid("observability.otlp"))?; + Ok(()) +} + +fn postgres_pool_config( + config: &DatabaseSettings, +) -> Result { + 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> { + if let Some(url) = &config.url { + return url + .expose_secret() + .parse::() + .map_err(|_| io::Error::other("database URL is invalid").into()); } - - let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into()); - let port = env::var("POSTGRES_PORT") - .ok() - .and_then(|value| value.parse::().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)) + .host(&config.host) + .port(config.port) + .database(&config.database) + .username(&config.username) + .password(config.password.expose_secret())) } -fn mcp_api_rate_limit_config_from_env() -> Result> -{ - let requests_per_second = env::var("CRANK_MCP_RATE_LIMIT_RPS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(60); - let burst = env::var("CRANK_MCP_RATE_LIMIT_BURST") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(120); - - Ok(RequestRateLimitConfig::new(requests_per_second, burst)?) +fn runtime_cache_config( + config: &McpProcessConfig, +) -> Result { + 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()), + ) } diff --git a/apps/mcp-server/tests/config_startup.rs b/apps/mcp-server/tests/config_startup.rs new file mode 100644 index 0000000..7be9858 --- /dev/null +++ b/apps/mcp-server/tests/config_startup.rs @@ -0,0 +1,65 @@ +use std::process::Command; + +fn run_with(entries: &[(&str, &str)]) -> String { + let mut command = Command::new(env!("CARGO_BIN_EXE_mcp-server")); + for field in crank_config::field_registry() { + command.env_remove(field.env_name); + } + command.env("CRANK_MASTER_KEY", "master"); + for (name, value) in entries { + command.env(name, value); + } + let output = command.output().expect("MCP 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![("CRANK_MCP_REFRESH_MS", "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("mcp_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); +} diff --git a/apps/mcp-server/tests/integration.rs b/apps/mcp-server/tests/integration.rs index feffabd..b8ca776 100644 --- a/apps/mcp-server/tests/integration.rs +++ b/apps/mcp-server/tests/integration.rs @@ -1,6 +1,9 @@ mod integration { mod catalog_access; mod common; + mod execution_stages; + mod jsonrpc_correlation; + mod request_context; mod tool_search; mod transport_protocol; } diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index 835847b..4a9b043 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -466,6 +466,10 @@ pub(super) async fn stream_logs() pub(super) async fn test_registry() -> PostgresRegistry { let database_url = crank_test_support::postgres_schema_url("test_mcp_server").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + crank_registry::MigrationAuthority::apply(&pool) + .await + .unwrap(); PostgresRegistry::connect(&database_url).await.unwrap() } diff --git a/apps/mcp-server/tests/integration/execution_stages.rs b/apps/mcp-server/tests/integration/execution_stages.rs index 7e15f2c..71abb29 100644 --- a/apps/mcp-server/tests/integration/execution_stages.rs +++ b/apps/mcp-server/tests/integration/execution_stages.rs @@ -154,6 +154,20 @@ async fn exports_real_tool_stages_without_sensitive_data() { .await; assert_eq!(call_result.status(), StatusCode::OK); + assert_eq!( + call_result + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()), + Some(REQUEST_ID), + ); + assert_eq!( + call_result + .headers() + .get("x-trace-id") + .and_then(|value| value.to_str().ok()), + Some(REMOTE_TRACE_ID), + ); let body = to_bytes(call_result.into_body(), 1024 * 1024) .await .unwrap(); @@ -235,6 +249,16 @@ async fn exports_real_tool_stages_without_sensitive_data() { assert!(!runtime.parent_span_id.is_empty()); assert_eq!(runtime.parent_span_id, root.span_id); + let upstream = trace_spans + .iter() + .find(|span| span.name == "upstream.http") + .expect("upstream attempt"); + assert_eq!( + decode_span_id(&traceparent[36..52]).as_slice(), + upstream.span_id.as_slice(), + "outbound traceparent must identify the actual client attempt span", + ); + let history = trace_spans .iter() .find(|span| span.name == "history.write") @@ -263,6 +287,7 @@ async fn exports_real_tool_stages_without_sensitive_data() { .unwrap(); assert_eq!(logs.len(), 1); assert_eq!(logs[0].log.request_id.as_deref(), Some(REQUEST_ID)); + assert_eq!(logs[0].log.trace_id.as_deref(), Some(REMOTE_TRACE_ID)); provider.shutdown().unwrap(); } @@ -310,6 +335,14 @@ fn decode_trace_id(value: &str) -> [u8; 16] { bytes } +fn decode_span_id(value: &str) -> [u8; 8] { + let mut bytes = [0_u8; 8]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).unwrap(); + } + bytes +} + fn string_attribute<'a>(span: &'a Span, key: &str) -> Option<&'a str> { span.attributes.iter().find_map(|attribute| { let value = attribute.value.as_ref()?.value.as_ref()?; diff --git a/apps/mcp-server/tests/integration/jsonrpc_correlation.rs b/apps/mcp-server/tests/integration/jsonrpc_correlation.rs new file mode 100644 index 0000000..f503e64 --- /dev/null +++ b/apps/mcp-server/tests/integration/jsonrpc_correlation.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use crank_core::PlatformApiKeyScope; +use crank_registry::PublishRequest; +use serde_json::{Value, json}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use super::common::{ + agent_mcp_url, build_test_app, create_platform_api_key, initialize_session, + post_jsonrpc_response, publish_agent_for_operation, spawn_mcp_server, spawn_upstream_server, + test_operation, test_registry, test_workspace_id, +}; + +#[tokio::test] +async fn generic_jsonrpc_errors_carry_the_same_safe_response_ids() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url, "crm_error_identity"); + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-error-identity").await; + let api_key = create_platform_api_key( + ®istry, + "sales-error-identity", + "mcp-error-identity", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app(registry, Duration::ZERO, None)).await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-error-identity"); + let session = initialize_session(&client, &mcp_url, &api_key).await; + + let response = post_jsonrpc_response( + &client, + &mcp_url, + &api_key, + Some(&session), + Some("jsonrpc-error-request"), + json!({ + "jsonrpc": "2.0", + "id": 91, + "method": "unsupported/method", + "params": {} + }), + ) + .await; + let request_id = response.headers()["x-request-id"] + .to_str() + .unwrap() + .to_owned(); + let trace_id = response.headers()["x-trace-id"] + .to_str() + .unwrap() + .to_owned(); + let payload = response.json::().await.unwrap(); + + assert_eq!(request_id, "jsonrpc-error-request"); + assert_eq!(payload["error"]["data"]["request_id"], request_id); + assert_eq!(payload["error"]["data"]["trace_id"], trace_id); +} diff --git a/apps/mcp-server/tests/integration/request_context.rs b/apps/mcp-server/tests/integration/request_context.rs index df44614..b2656bb 100644 --- a/apps/mcp-server/tests/integration/request_context.rs +++ b/apps/mcp-server/tests/integration/request_context.rs @@ -9,7 +9,7 @@ use axum::{ }; use opentelemetry::{ global, - trace::{TraceId, TracerProvider as _}, + trace::{SpanId, TraceId, TracerProvider as _}, }; use opentelemetry_sdk::{ error::OTelSdkResult, @@ -61,6 +61,7 @@ async fn covers_valid_invalid_and_absent_traceparent_on_mcp_boundary() { assert_eq!(valid.status, StatusCode::OK); assert_eq!(valid.request_id.as_deref(), Some("request-id-is-separate")); + assert_eq!(valid.trace_id.as_deref(), Some(REMOTE_TRACE_ID)); assert_eq!(invalid.status, StatusCode::OK); assert_eq!(absent.status, StatusCode::OK); assert!(valid.traceparent_response.is_none()); @@ -80,6 +81,41 @@ async fn covers_valid_invalid_and_absent_traceparent_on_mcp_boundary() { assert_ne!(trace_ids[2], trace_ids[0]); assert_ne!(trace_ids[1], trace_ids[2]); assert!(!trace_ids.contains(&TraceId::INVALID)); + let request_spans = exported + .lock() + .unwrap() + .iter() + .filter(|span| span.name.as_ref() == "mcp.request") + .cloned() + .collect::>(); + assert_eq!( + request_spans[0].parent_span_id.to_string(), + "b7ad6b7169203331" + ); + assert_eq!(request_spans[1].parent_span_id, SpanId::INVALID); + assert_eq!(request_spans[2].parent_span_id, SpanId::INVALID); + provider.shutdown().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sampling_off_still_returns_a_local_trace_identity() { + let _tracing_test_guard = TRACING_TEST_LOCK.lock().await; + global::set_text_map_propagator(TraceContextPropagator::new()); + let provider = SdkTracerProvider::builder() + .with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOff) + .build(); + let tracer = provider.tracer("mcp-request-context-sampling-off-test"); + let subscriber = + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); + let app = build_test_app(test_registry().await, Duration::ZERO, None); + + let response = send_health(app, None, None) + .with_subscriber(subscriber) + .await; + + let trace_id = response.trace_id.expect("local trace id"); + assert_eq!(trace_id.len(), 32); + assert_ne!(trace_id, "00000000000000000000000000000000"); provider.shutdown().unwrap(); } @@ -98,6 +134,14 @@ async fn replaces_multiple_request_id_headers_with_one_uuid_v7() { "x-request-id", HeaderValue::from_static("second-request-id"), ); + request.headers_mut().append( + "traceparent", + HeaderValue::from_static("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + ); + request.headers_mut().append( + "traceparent", + HeaderValue::from_static("00-1af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + ); let response = app .oneshot(request) @@ -112,6 +156,9 @@ async fn replaces_multiple_request_id_headers_with_one_uuid_v7() { uuid::Uuid::parse_str(generated).unwrap().get_version(), Some(uuid::Version::SortRand) ); + let trace_id = response.headers()["x-trace-id"].to_str().unwrap(); + assert_ne!(trace_id, "0af7651916cd43dd8448eb211c80319c"); + assert_ne!(trace_id, "1af7651916cd43dd8448eb211c80319c"); } async fn send_health( @@ -143,6 +190,11 @@ async fn send_health( .get("traceparent") .and_then(|value| value.to_str().ok()) .map(str::to_owned), + trace_id: response + .headers() + .get("x-trace-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), } } @@ -150,6 +202,7 @@ struct ProbeResponse { status: StatusCode, request_id: Option, traceparent_response: Option, + trace_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/mcp-server/tests/integration/transport_protocol.rs b/apps/mcp-server/tests/integration/transport_protocol.rs index 50a5e84..809008b 100644 --- a/apps/mcp-server/tests/integration/transport_protocol.rs +++ b/apps/mcp-server/tests/integration/transport_protocol.rs @@ -322,6 +322,10 @@ async fn preserves_request_id_for_tool_call_invocations() { response.headers()["x-request-id"].to_str().unwrap(), "req_test_123" ); + let trace_id = response.headers()["x-trace-id"] + .to_str() + .unwrap() + .to_owned(); let call_result = response.json::().await.unwrap(); assert_eq!(call_result["result"]["isError"], false); @@ -341,6 +345,7 @@ async fn preserves_request_id_for_tool_call_invocations() { assert_eq!(logs.len(), 1); assert_eq!(logs[0].log.request_id.as_deref(), Some("req_test_123")); + assert_eq!(logs[0].log.trace_id.as_deref(), Some(trace_id.as_str())); } #[tokio::test] @@ -408,6 +413,13 @@ async fn generates_request_id_for_tool_call_responses_and_logs() { .to_str() .unwrap() .to_owned(); + let trace_id = response + .headers() + .get("x-trace-id") + .unwrap() + .to_str() + .unwrap() + .to_owned(); assert_eq!( uuid::Uuid::parse_str(&request_id).unwrap().get_version(), Some(Version::SortRand) @@ -432,6 +444,7 @@ async fn generates_request_id_for_tool_call_responses_and_logs() { assert_eq!(logs.len(), 1); assert_eq!(logs[0].log.request_id.as_deref(), Some(request_id.as_str())); + assert_eq!(logs[0].log.trace_id.as_deref(), Some(trace_id.as_str())); } #[tokio::test(flavor = "current_thread")] diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 5a9557e..65b409a 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -8,6 +8,18 @@ }, extra || {}); } + function attachCorrelation(error, response) { + var requestId = response.headers.get('x-request-id'); + var traceId = response.headers.get('x-trace-id'); + if (requestId && requestId.length <= 128 && /^[!-~]+$/.test(requestId) && requestId.indexOf(',') === -1 && requestId.indexOf(';') === -1) { + error.requestId = requestId; + } + if (traceId && /^[0-9a-f]{32}$/.test(traceId) && traceId !== '00000000000000000000000000000000') { + error.traceId = traceId; + } + return error; + } + async function request(path, options) { var response = await fetch(path, Object.assign({ credentials: 'same-origin', @@ -42,11 +54,11 @@ var error = new Error(message); error.status = response.status; error.payload = payload; - throw error; + throw attachCorrelation(error, response); } if (text && payload === null) { - throw new Error('Backend returned a non-JSON response'); + throw attachCorrelation(new Error('Backend returned a non-JSON response'), response); } return payload; @@ -81,7 +93,7 @@ var error = new Error(message); error.status = response.status; error.payload = payload; - throw error; + throw attachCorrelation(error, response); } return text; diff --git a/apps/ui/playwright.config.js b/apps/ui/playwright.config.js index 0b4af18..7b553a7 100644 --- a/apps/ui/playwright.config.js +++ b/apps/ui/playwright.config.js @@ -26,10 +26,14 @@ module.exports = defineConfig({ : { command: 'bash scripts/playwright-stack.sh', cwd: __dirname, - url: `${baseURL}/login`, - timeout: 600_000, - reuseExistingServer: false, + url: `${baseURL}/login`, + timeout: 600_000, + reuseExistingServer: false, + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 10_000, }, + }, projects: [ { name: 'chromium', diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh index 62d4c48..9725a14 100644 --- a/apps/ui/scripts/playwright-stack.sh +++ b/apps/ui/scripts/playwright-stack.sh @@ -67,7 +67,8 @@ cleanup() { kill_port_processes "$MCP_PORT" } -trap cleanup EXIT INT TERM +trap cleanup EXIT +trap 'exit 130' INT TERM cleanup @@ -131,7 +132,13 @@ mkdir -p "$CRANK_STORAGE_ROOT" ( cd "$ROOT_DIR" - cargo run -p admin-api >"$LOG_DIR/admin-api.log" 2>&1 + cargo run -p admin-api --bin crank-migrate -- apply >"$LOG_DIR/migrate.log" 2>&1 +) + +( + cd "$ROOT_DIR" + exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS \ + cargo run -p admin-api --bin admin-api >"$LOG_DIR/admin-api.log" 2>&1 ) & echo $! > "$TMP_DIR/admin-api.pid" @@ -141,7 +148,11 @@ done ( cd "$ROOT_DIR" - cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1 + exec env -u CRANK_ADMIN_BIND -u CRANK_STORAGE_ROOT -u CRANK_SESSION_SECRET \ + -u CRANK_PASSWORD_PEPPER -u CRANK_SESSION_TTL_HOURS \ + -u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_PASSWORD \ + -u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \ + cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1 ) & echo $! > "$TMP_DIR/mcp-server.pid" @@ -151,7 +162,7 @@ done ( cd "$ROOT_DIR/apps/ui" - node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1 + exec node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1 ) & echo $! > "$TMP_DIR/ui-server.pid" diff --git a/apps/ui/tests/e2e/observability.spec.js b/apps/ui/tests/e2e/observability.spec.js index 9e2597c..cf99cce 100644 --- a/apps/ui/tests/e2e/observability.spec.js +++ b/apps/ui/tests/e2e/observability.spec.js @@ -28,3 +28,47 @@ test('secrets page exposes stable secret management hooks', async ({ page }) => await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-crank-bootstrap-state', 'ready'); }); + +test('API errors retain only bounded canonical support identities', async ({ page }) => { + await login(page); + await page.route('**/api/admin/workspaces/correlation-*/operations', async (route) => { + var hostile = route.request().url().includes('correlation-hostile'); + await route.fulfill({ + status: 503, + contentType: 'application/json', + headers: hostile + ? { 'x-request-id': 'reflected;attacker', 'x-trace-id': 'NOT-A-TRACE' } + : { + 'x-request-id': '01J5SAFELOCALREQUEST', + 'x-trace-id': '0123456789abcdef0123456789abcdef', + }, + body: JSON.stringify({ error: { message: 'safe failure' } }), + }); + }); + + var safe = await page.evaluate(async () => { + try { + await window.CrankApi.listOperations('correlation-safe'); + return null; + } catch (error) { + return { requestId: error.requestId, traceId: error.traceId }; + } + }); + expect(safe).toEqual({ + requestId: '01J5SAFELOCALREQUEST', + traceId: '0123456789abcdef0123456789abcdef', + }); + + var hostile = await page.evaluate(async () => { + try { + await window.CrankApi.listOperations('correlation-hostile'); + return null; + } catch (error) { + return { + hasRequestId: Object.prototype.hasOwnProperty.call(error, 'requestId'), + hasTraceId: Object.prototype.hasOwnProperty.call(error, 'traceId'), + }; + } + }); + expect(hostile).toEqual({ hasRequestId: false, hasTraceId: false }); +}); diff --git a/crates/crank-adapter-rest/src/client.rs b/crates/crank-adapter-rest/src/client.rs index 30d9bf4..09d77a8 100644 --- a/crates/crank-adapter-rest/src/client.rs +++ b/crates/crank-adapter-rest/src/client.rs @@ -1,16 +1,20 @@ use std::{ collections::BTreeMap, - env, io, + io, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, sync::Arc, time::Duration, }; -use crank_core::{HttpMethod, RestTarget}; +use crank_core::{HttpMethod, RestTarget, RuntimeRequestContext}; use crank_metrics::{UpstreamOperationKind, UpstreamOutcome, UpstreamRequestMetrics}; use crank_trace::{ErrorCategory, Stage, StageOutcome}; use futures_util::StreamExt; -use opentelemetry::{global, propagation::Injector, trace::TraceContextExt}; +use opentelemetry::{ + Context, global, + propagation::Injector, + trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState}, +}; use reqwest::{ Client, dns::{Addrs, Name, Resolve, Resolving}, @@ -49,10 +53,6 @@ impl RestAdapter { Self::with_policy(OutboundHttpPolicy::default()) } - pub fn from_env() -> Result { - Ok(Self::with_policy(OutboundHttpPolicy::from_env()?)) - } - pub fn with_policy(policy: OutboundHttpPolicy) -> Self { let resolver = Arc::new(PolicyDnsResolver { policy: policy.clone(), @@ -73,7 +73,23 @@ impl RestAdapter { request: &RestRequest, ) -> Result { let request_metrics = UpstreamRequestMetrics::start(UpstreamOperationKind::Rest); - let result = self.execute_inner(target, request).await; + let result = self.execute_inner(target, request, None).await; + let outcome = match &result { + Ok(_) => UpstreamOutcome::Success, + Err(error) => upstream_outcome(error), + }; + request_metrics.complete(outcome); + result + } + + pub(crate) async fn execute_with_context( + &self, + target: &RestTarget, + request: &RestRequest, + context: &RuntimeRequestContext, + ) -> Result { + let request_metrics = UpstreamRequestMetrics::start(UpstreamOperationKind::Rest); + let result = self.execute_inner(target, request, Some(context)).await; let outcome = match &result { Ok(_) => UpstreamOutcome::Success, Err(error) => upstream_outcome(error), @@ -86,28 +102,48 @@ impl RestAdapter { &self, target: &RestTarget, request: &RestRequest, + trusted_context: Option<&RuntimeRequestContext>, ) -> Result { - let url = build_url(target, request)?; - self.policy.validate_url(&url)?; - let mut headers = build_headers(target, request)?; - apply_current_trace_context(&mut headers); - let client = - self.client - .as_ref() - .map_err(|details| RestAdapterError::InvalidConfiguration { - details: details.to_string(), - })?; - let mut builder = client - .request(to_reqwest_method(target.method), url) - .headers(headers) - .timeout(Duration::from_millis(request.timeout_ms)); - - if let Some(body) = &request.body { - builder = builder.json(body); - } - let upstream_span = Stage::UpstreamHttp.span(); + if let Some(context) = trusted_context { + set_span_parent_from_traceparent(&upstream_span, context.trace_context.traceparent()); + } let result = async { + let url = build_url(target, request)?; + self.policy.validate_url(&url)?; + let mut headers = build_headers(target, request)?; + if let Some(context) = trusted_context { + for (name, value) in context.outbound_headers() { + let (Ok(name), Ok(value)) = + (HeaderName::try_from(name), HeaderValue::try_from(value)) + else { + continue; + }; + headers.insert(name, value); + } + } + apply_current_trace_context(&mut headers); + if !headers.contains_key("traceparent") + && let Some(context) = trusted_context + && let Ok(value) = HeaderValue::from_str(context.trace_context.traceparent()) + { + headers.insert("traceparent", value); + } + let client = + self.client + .as_ref() + .map_err(|details| RestAdapterError::InvalidConfiguration { + details: details.to_string(), + })?; + let mut builder = client + .request(to_reqwest_method(target.method), url) + .headers(headers) + .timeout(Duration::from_millis(request.timeout_ms)); + + if let Some(body) = &request.body { + builder = builder.json(body); + } + let response = builder.send().await?; let status = response.status(); let headers = normalize_headers(response.headers()); @@ -174,32 +210,19 @@ impl Default for OutboundHttpPolicy { } impl OutboundHttpPolicy { - pub fn from_env() -> Result { - let max_response_bytes = match env::var("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") { - Ok(value) => { - value - .parse::() - .map_err(|_| RestAdapterError::InvalidConfiguration { - details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be a positive integer" - .to_owned(), - })? - } - Err(env::VarError::NotPresent) => DEFAULT_MAX_RESPONSE_BYTES, - Err(error) => { - return Err(RestAdapterError::InvalidConfiguration { - details: error.to_string(), - }); - } - }; + pub fn try_new( + allowed_hosts: Vec, + denied_hosts: Vec, + max_response_bytes: usize, + ) -> Result { if max_response_bytes == 0 { return Err(RestAdapterError::InvalidConfiguration { - details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be greater than zero".to_owned(), + details: "outbound response limit must be greater than zero".to_owned(), }); } - Ok(Self { - allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?, - denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?, + allowed_hosts: validate_host_patterns(allowed_hosts)?, + denied_hosts: validate_host_patterns(denied_hosts)?, max_response_bytes, }) } @@ -308,20 +331,11 @@ fn boxed_io_error(message: String) -> Box { Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message)) } -fn host_patterns_from_env(name: &str) -> Result, RestAdapterError> { - let value = match env::var(name) { - Ok(value) => value, - Err(env::VarError::NotPresent) => return Ok(Vec::new()), - Err(error) => { - return Err(RestAdapterError::InvalidConfiguration { - details: error.to_string(), - }); - } - }; - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) +fn validate_host_patterns( + values: impl IntoIterator, +) -> Result, RestAdapterError> { + values + .into_iter() .map(|value| { let wildcard = value.starts_with("*."); let normalized = normalize_host(value.trim_start_matches("*.")); @@ -332,7 +346,7 @@ fn host_patterns_from_env(name: &str) -> Result, RestAdapterError> { || (wildcard && normalized.parse::().is_ok()) { return Err(RestAdapterError::InvalidConfiguration { - details: format!("{name} contains an invalid host pattern: {value}"), + details: "outbound host pattern is invalid".to_owned(), }); } Ok(if wildcard { @@ -465,7 +479,7 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName { header: name.to_owned(), })?; - if is_trace_propagation_header(&header_name) { + if is_reserved_correlation_header(&header_name) { return Ok(()); } let header_value = @@ -477,12 +491,53 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), Ok(()) } -fn is_trace_propagation_header(name: &HeaderName) -> bool { - matches!(name.as_str(), "traceparent" | "tracestate" | "baggage") +fn is_reserved_correlation_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "traceparent" + | "tracestate" + | "baggage" + | "x-request-id" + | "x-trace-id" + | "x-correlation-id" + ) +} + +fn set_span_parent_from_traceparent(span: &Span, traceparent: &str) -> bool { + let mut parts = traceparent.split('-'); + let (Some("00"), Some(trace_id), Some(parent_id), Some(flags), None) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return false; + }; + let (Ok(trace_id), Ok(parent_id)) = (TraceId::from_hex(trace_id), SpanId::from_hex(parent_id)) + else { + return false; + }; + let trace_flags = if flags == "01" { + TraceFlags::SAMPLED + } else if flags == "00" { + TraceFlags::default() + } else { + return false; + }; + let parent = SpanContext::new( + trace_id, + parent_id, + trace_flags, + true, + TraceState::default(), + ); + span.set_parent(Context::new().with_remote_span_context(parent)) + .is_ok() } fn apply_current_trace_context(headers: &mut HeaderMap) { - for header in ["traceparent", "tracestate", "baggage"] { + for header in ["tracestate", "baggage"] { headers.remove(header); } @@ -490,6 +545,7 @@ fn apply_current_trace_context(headers: &mut HeaderMap) { if !context.span().span_context().is_valid() { return; } + headers.remove("traceparent"); global::get_text_map_propagator(|propagator| { propagator.inject_context(&context, &mut ReqwestHeaderInjector(headers)); }); diff --git a/crates/crank-adapter-rest/src/lib.rs b/crates/crank-adapter-rest/src/lib.rs index 10d1be6..cc2bf0b 100644 --- a/crates/crank-adapter-rest/src/lib.rs +++ b/crates/crank-adapter-rest/src/lib.rs @@ -29,16 +29,14 @@ impl ProtocolAdapter for RestAdapter { context: &RuntimeRequestContext, ) -> Result { let target = rest_target(target)?; - let mut headers = prepared.headers.clone(); - headers.extend(context.outbound_headers()); let request = RestRequest { path_params: prepared.path_params.clone(), query_params: prepared.query_params.clone(), - headers, + headers: prepared.headers.clone(), body: prepared.body.clone(), timeout_ms: prepared.timeout_ms, }; - let response = self.execute(target, &request).await?; + let response = self.execute_with_context(target, &request, context).await?; Ok(AdapterResponse { status_code: response.status_code, diff --git a/crates/crank-adapter-rest/tests/integration/client.rs b/crates/crank-adapter-rest/tests/integration/client.rs index 5edc816..8c6f5ac 100644 --- a/crates/crank-adapter-rest/tests/integration/client.rs +++ b/crates/crank-adapter-rest/tests/integration/client.rs @@ -48,7 +48,7 @@ async fn executes_rest_request_and_normalizes_json_response() { json!({ "id": "42", "query": "true", - "trace": "trace-123", + "trace": "", "static": "static", "payload": { "name": "Ada" } }) @@ -69,6 +69,7 @@ async fn protocol_context_overrides_mapped_correlation_headers() { "x-correlation-id".to_owned(), "static-correlation".to_owned(), ), + ("x-trace-id".to_owned(), "static-trace".to_owned()), ]), }); let prepared = PreparedRequest { @@ -79,12 +80,17 @@ async fn protocol_context_overrides_mapped_correlation_headers() { "x-correlation-id".to_owned(), "mapped-correlation".to_owned(), ), + ("x-trace-id".to_owned(), "mapped-trace".to_owned()), ]), body: Some(json!({ "name": "Ada" })), timeout_ms: 1_000, ..PreparedRequest::default() }; - let context = RuntimeRequestContext::new("req-runtime", "corr-runtime"); + let context = RuntimeRequestContext::new( + crank_core::RequestId::resolve(Some("req-runtime")), + crank_core::TraceContext::parse("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + .unwrap(), + ); let response = adapter .invoke_unary(&target, &prepared, &context) @@ -92,7 +98,12 @@ async fn protocol_context_overrides_mapped_correlation_headers() { .unwrap(); assert_eq!(response.body["request_id"], "req-runtime"); - assert_eq!(response.body["correlation_id"], "corr-runtime"); + assert_eq!(response.body["correlation_id"], "req-runtime"); + assert_eq!(response.body["trace"], "0af7651916cd43dd8448eb211c80319c"); + assert_eq!( + &response.body["traceparent"].as_str().unwrap()[3..35], + "0af7651916cd43dd8448eb211c80319c" + ); } #[tokio::test(flavor = "current_thread")] diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index 750f2cf..14ad8b8 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -14,7 +14,7 @@ use axum::{ }; use crank_core::{ ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore, - InvocationLevel, InvocationSource, InvocationStatus, OperationApprovalMode, + CorrelationContext, InvocationLevel, InvocationSource, InvocationStatus, OperationApprovalMode, PlatformApiKeyScope, SecretId, }; use crank_registry::{ @@ -64,10 +64,12 @@ use crate::{ mod invocation_history; mod metrics; mod stages; +mod tool_resolution; use self::metrics::{ActiveStreamGuard, McpRequestMetrics}; use self::stages::{ enforce_traced_rate_limit, require_traced_approval_access, require_traced_machine_access, }; +pub(super) use self::tool_resolution::{resolve_generated_tool, runtime_operation}; #[cfg(test)] use invocation_history::observe_invocation_history_outcome; pub(super) use invocation_history::{InvocationRecord, persist_invocation}; @@ -298,13 +300,13 @@ async fn readiness(State(state): State>) -> Response { "checks": { "postgres": "ready" } })) .into_response(), - Err(error) => ( + Err(_) => ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "service": "mcp-server", "status": "not_ready", "checks": { "postgres": "not_ready" }, - "error": error.to_string() + "error": "database is unavailable" })), ) .into_response(), @@ -363,7 +365,7 @@ async fn approve_request( payload, PlatformApiKeyScope::Approve, ApprovalRequestStatus::Approved, - Some(request_context.request_id), + Some(request_context.correlation), ) .await } @@ -425,7 +427,7 @@ async fn decide_approval_request( payload: ApprovalDecisionPayload, required_scope: PlatformApiKeyScope, status: ApprovalRequestStatus, - execution_request_id: Option, + execution_correlation: Option, ) -> Response { let agent_path = AgentRoutePath { workspace_slug: path.workspace_slug, @@ -496,7 +498,7 @@ async fn decide_approval_request( &state, &agent_path, claimed, - execution_request_id.as_deref(), + execution_correlation.as_ref(), ) .await { @@ -719,11 +721,12 @@ async fn mcp_post( let mut request_metrics = McpRequestMetrics::invalid(); let response = rejection.into_response(); request_metrics.complete(&response); - return with_request_id_header(response, &request_context.request_id); + return with_request_id_header(response, request_context.request_id()); } }; let mut request_metrics = McpRequestMetrics::new(&message); - let transport_request_id = request_context.request_id; + let transport_correlation = request_context.correlation; + let transport_request_id = transport_correlation.request_id().to_string(); info!( name: "mcp.request.received", request_id = %transport_request_id, @@ -733,7 +736,8 @@ async fn mcp_post( "mcp request received" ); - let response = mcp_post_response(&path, state, &headers, &message, &transport_request_id).await; + let response = + mcp_post_response(&path, state, &headers, &message, &transport_correlation).await; request_metrics.complete(&response); with_request_id_header(response, &transport_request_id) } @@ -743,7 +747,7 @@ async fn mcp_post_response( state: Arc, headers: &HeaderMap, message: &Value, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Response { if let Err(status) = validate_origin(&state.allowed_origins, headers) { return status.into_response(); @@ -851,10 +855,10 @@ async fn mcp_post_response( }; let tool_call_params: ToolCallParams = match serde_json::from_value(params(message)) { Ok(value) => value, - Err(error) => { + Err(_) => { return transport_response( StatusCode::OK, - jsonrpc_error(request_id(message), -32602, error.to_string()), + jsonrpc_error(request_id(message), -32602, "invalid tool call parameters"), response_mode, None, Some(&session.protocol_version), @@ -883,7 +887,7 @@ async fn mcp_post_response( &catalog, &tool_call_params.name, arguments, - transport_request_id, + transport_correlation, ) .await } @@ -925,8 +929,9 @@ pub(super) async fn handle_tool_call( resolved: ResolvedToolCall, arguments: Value, confirmation_token: Option, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Response { + let transport_request_id = transport_correlation.request_id().as_str(); if !credential_allows_security_level(credential, resolved.tool.operation.security_level) { return tool_error_response( message, @@ -940,6 +945,7 @@ pub(super) async fn handle_tool_call( serialize_security_level(resolved.tool.operation.security_level), ), transport_request_id, + transport_correlation.trace_id().as_str(), false, Some("Используйте ключ агента с достаточным уровнем доступа."), ), @@ -956,7 +962,7 @@ pub(super) async fn handle_tool_call( arguments, confirmation_token, }, - transport_request_id, + transport_correlation, ) .await } @@ -1076,8 +1082,9 @@ async fn handle_base_tool_call( message: &Value, response_mode: ResponseMode, execution: ToolCallExecution, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Response { + let transport_request_id = transport_correlation.request_id().as_str(); let tool = execution.tool; let arguments = execution.arguments; let operation = runtime_operation(&tool); @@ -1096,7 +1103,7 @@ async fn handle_base_tool_call( response_mode, &tool, &arguments, - transport_request_id, + transport_correlation, ) .instrument(approval_span.clone()) .await; @@ -1116,16 +1123,17 @@ async fn handle_base_tool_call( StageOutcome::Allowed.record(&approval_span); } - let mut runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id) - .with_response_cache_scope( - tool.workspace_id.as_str().to_owned(), - tool.agent_id.as_str().to_owned(), - ) - .with_metering_context( - tool.workspace_id.clone(), - Some(tool.agent_id.clone()), - InvocationSource::AgentToolCall, - ); + let mut runtime_request_context = + RuntimeRequestContext::from_correlation(transport_correlation) + .with_response_cache_scope( + tool.workspace_id.as_str().to_owned(), + tool.agent_id.as_str().to_owned(), + ) + .with_metering_context( + tool.workspace_id.clone(), + Some(tool.agent_id.clone()), + InvocationSource::AgentToolCall, + ); if let Some(token) = execution.confirmation_token { runtime_request_context = runtime_request_context.with_confirmation_token(token); } @@ -1155,6 +1163,7 @@ async fn handle_base_tool_call( &tool, InvocationRecord { request_id: Some(transport_request_id), + trace_id: Some(transport_correlation.trace_id().as_str()), tool_name: &tool.tool_name, status: InvocationStatus::Ok, level: InvocationLevel::Info, @@ -1176,10 +1185,11 @@ async fn handle_base_tool_call( &tool, InvocationRecord { request_id: Some(transport_request_id), + trace_id: Some(transport_correlation.trace_id().as_str()), tool_name: &tool.tool_name, status: InvocationStatus::Error, level: InvocationLevel::Error, - message: &error.to_string(), + message: runtime_error_code(&error), status_code: None, error_kind: Some(runtime_error_code(&error)), duration: started_at.elapsed(), @@ -1193,7 +1203,11 @@ async fn handle_base_tool_call( message, response_mode, &session.protocol_version, - tool_error_contract_from_runtime(&error, transport_request_id), + tool_error_contract_from_runtime( + &error, + transport_request_id, + transport_correlation.trace_id().as_str(), + ), ) } } @@ -1211,7 +1225,7 @@ async fn maybe_handle_approval_policy( response_mode: ResponseMode, tool: &PublishedAgentTool, arguments: &Value, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Option { let policy = tool.operation.execution_config.approval_policy.as_ref()?; if !policy.required { @@ -1227,7 +1241,7 @@ async fn maybe_handle_approval_policy( response_mode, tool, arguments, - transport_request_id, + transport_correlation, ) .await } @@ -1238,7 +1252,7 @@ async fn maybe_handle_approval_policy( tool, arguments, policy.elicitation_message.as_deref(), - transport_request_id, + transport_correlation, )), } } @@ -1250,8 +1264,9 @@ async fn maybe_create_custom_pending_approval( response_mode: ResponseMode, tool: &PublishedAgentTool, arguments: &Value, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Option { + let transport_request_id = transport_correlation.request_id().as_str(); let policy = tool.operation.execution_config.approval_policy.as_ref()?; let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple())); @@ -1298,6 +1313,7 @@ async fn maybe_create_custom_pending_approval( tool, InvocationRecord { request_id: Some(transport_request_id), + trace_id: Some(transport_correlation.trace_id().as_str()), tool_name: &tool.tool_name, status: InvocationStatus::Ok, level: InvocationLevel::Info, @@ -1326,8 +1342,9 @@ fn handle_elicitation_approval( tool: &PublishedAgentTool, arguments: &Value, elicitation_message: Option<&str>, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> ApprovalPolicyResult { + let transport_request_id = transport_correlation.request_id().as_str(); if !session.supports_elicitation { return ApprovalPolicyResult::Error(tool_error_response( message, @@ -1337,6 +1354,7 @@ fn handle_elicitation_approval( "approval_elicitation_not_supported", "operation requires MCP Elicitation, but the MCP client did not advertise elicitation capability", transport_request_id, + transport_correlation.trace_id().as_str(), false, Some( "Выберите Custom MCP Approval или подключите MCP-клиент с поддержкой elicitation.", @@ -1375,10 +1393,10 @@ async fn handle_initialize( ) -> Response { let initialize_params: InitializeParams = match serde_json::from_value(params(message)) { Ok(value) => value, - Err(error) => { + Err(_error) => { return transport_response( StatusCode::OK, - jsonrpc_error(request_id(message), -32602, error.to_string()), + jsonrpc_error(request_id(message), -32602, "invalid initialize parameters"), response_mode, None, Some(DEFAULT_PROTOCOL_VERSION), @@ -1513,10 +1531,10 @@ async fn require_initialized_session( Ok(session) } -fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Response { +fn internal_jsonrpc_error(message: &Value, _error: impl std::fmt::Display) -> Response { transport_response( StatusCode::INTERNAL_SERVER_ERROR, - jsonrpc_error(request_id(message), -32603, error.to_string()), + jsonrpc_error(request_id(message), -32603, "internal server error"), ResponseMode::Json, None, Some(DEFAULT_PROTOCOL_VERSION), @@ -1614,26 +1632,5 @@ fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime { timestamp + delta } -pub(super) fn resolve_generated_tool( - tools: &[PublishedAgentTool], - tool_name: &str, -) -> Option { - for tool in tools { - if tool.tool_name == tool_name { - return Some(ResolvedToolCall { tool: tool.clone() }); - } - } - - None -} - -pub(super) fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { - let mut operation = RuntimeOperation::from(tool.operation.clone()); - operation.tool_name = tool.tool_name.clone(); - operation.tool_description.title = tool.tool_title.clone(); - operation.tool_description.description = tool.tool_description.clone(); - operation -} - #[cfg(test)] mod tests; diff --git a/crates/crank-community-mcp/src/app/invocation_history.rs b/crates/crank-community-mcp/src/app/invocation_history.rs index 9161d86..3c33c42 100644 --- a/crates/crank-community-mcp/src/app/invocation_history.rs +++ b/crates/crank-community-mcp/src/app/invocation_history.rs @@ -15,6 +15,7 @@ use super::AppState; pub(crate) struct InvocationRecord<'a> { pub(crate) request_id: Option<&'a str>, + pub(crate) trace_id: Option<&'a str>, pub(crate) tool_name: &'a str, pub(crate) status: InvocationStatus, pub(crate) level: InvocationLevel, @@ -42,6 +43,7 @@ pub(crate) async fn persist_invocation( tool_name: record.tool_name.to_owned(), message: record.message.to_owned(), request_id: record.request_id.map(ToOwned::to_owned), + trace_id: record.trace_id.map(ToOwned::to_owned), status_code: record.status_code, duration_ms: u64::try_from(record.duration.as_millis()).unwrap_or(u64::MAX), error_kind: record.error_kind.map(ToOwned::to_owned), @@ -79,6 +81,7 @@ pub(crate) async fn persist_invocation( observe_invocation_history_outcome( outcome, record.request_id, + record.trace_id, record.status, InvocationSource::AgentToolCall, ); @@ -88,6 +91,7 @@ pub(crate) async fn persist_invocation( pub(super) fn observe_invocation_history_outcome( outcome: InvocationHistoryWriteOutcome, request_id: Option<&str>, + trace_id: Option<&str>, status: InvocationStatus, source: InvocationSource, ) { @@ -100,6 +104,7 @@ pub(super) fn observe_invocation_history_outcome( warn!( name: "mcp.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(), diff --git a/crates/crank-community-mcp/src/app/tests.rs b/crates/crank-community-mcp/src/app/tests.rs index 1c263c2..68c2e56 100644 --- a/crates/crank-community-mcp/src/app/tests.rs +++ b/crates/crank-community-mcp/src/app/tests.rs @@ -37,6 +37,7 @@ async fn tool_error_response_includes_structured_context() { "streaming_payload_error", "request root must be an object", "req-1", + "0af7651916cd43dd8448eb211c80319c", false, Some("Проверьте параметры вызова инструмента."), ), @@ -59,6 +60,7 @@ async fn tool_error_response_includes_structured_context() { "message": "request root must be an object", "recoverable": false, "request_id": "req-1", + "trace_id": "0af7651916cd43dd8448eb211c80319c", "suggested_action": "Проверьте параметры вызова инструмента." }) ); @@ -146,6 +148,7 @@ fn emits_bounded_history_loss_incident() { category: InvocationHistoryLossCategory::Unavailable, }), Some("req_mcp_dc08"), + Some("0af7651916cd43dd8448eb211c80319c"), InvocationStatus::Ok, crank_core::InvocationSource::AgentToolCall, ); diff --git a/crates/crank-community-mcp/src/app/tool_resolution.rs b/crates/crank-community-mcp/src/app/tool_resolution.rs new file mode 100644 index 0000000..e8bb2b9 --- /dev/null +++ b/crates/crank-community-mcp/src/app/tool_resolution.rs @@ -0,0 +1,23 @@ +use crank_registry::PublishedAgentTool; +use crank_runtime::RuntimeOperation; + +use super::ResolvedToolCall; + +pub(crate) fn resolve_generated_tool( + tools: &[PublishedAgentTool], + tool_name: &str, +) -> Option { + tools + .iter() + .find(|tool| tool.tool_name == tool_name) + .cloned() + .map(|tool| ResolvedToolCall { tool }) +} + +pub(crate) fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { + let mut operation = RuntimeOperation::from(tool.operation.clone()); + operation.tool_name = tool.tool_name.clone(); + operation.tool_description.title = tool.tool_title.clone(); + operation.tool_description.description = tool.tool_description.clone(); + operation +} diff --git a/crates/crank-community-mcp/src/approval_execution.rs b/crates/crank-community-mcp/src/approval_execution.rs index 7459f4b..526dd69 100644 --- a/crates/crank-community-mcp/src/approval_execution.rs +++ b/crates/crank-community-mcp/src/approval_execution.rs @@ -5,7 +5,7 @@ use axum::{ response::{IntoResponse, Response}, }; use crank_core::{ApprovalRequestStatus, InvocationLevel, InvocationSource, InvocationStatus}; -use crank_observability::RequestId; +use crank_core::{CorrelationContext, RequestId, TraceContext}; use crank_registry::{ApprovalRequestRecord, FinishApprovalRequest}; use crank_runtime::{RuntimeExecutionRequest, RuntimeRequestContext}; use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query}; @@ -18,7 +18,7 @@ use crate::{ AgentRoutePath, AppState, InvocationRecord, build_request_preview, persist_invocation, resolve_operation_auth, runtime_operation, }, - tool_error::runtime_error_code, + tool_error::{runtime_error_code, safe_runtime_error_message}, }; const RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); @@ -64,7 +64,10 @@ async fn recover_approved_requests(state: &Arc) { continue; }; let recovery_span = Stage::ApprovalRecovery.span(); - let result = execute_approved_request(state, &path, approval, None) + let trace_context = crank_trace::trace_context_for_span(&recovery_span) + .unwrap_or_else(TraceContext::generate); + let correlation = CorrelationContext::new(RequestId::generate(), trace_context); + let result = execute_approved_request(state, &path, approval, Some(&correlation)) .instrument(recovery_span.clone()) .await; match &result { @@ -162,9 +165,12 @@ pub(super) async fn execute_approved_request( state: &Arc, path: &AgentRoutePath, approval: ApprovalRequestRecord, - request_id: Option<&str>, + correlation: Option<&CorrelationContext>, ) -> Result { - let request_id = RequestId::resolve(request_id).into_string(); + let correlation = correlation + .cloned() + .unwrap_or_else(CorrelationContext::generate); + let request_id = correlation.request_id().as_str(); let tools = state .catalog .list_tools(&path.workspace_slug, &path.agent_slug) @@ -184,7 +190,7 @@ pub(super) async fn execute_approved_request( &approval.approval.request_payload, ); let started_at = Instant::now(); - let runtime_request_context = RuntimeRequestContext::from_request_id(request_id.clone()) + let runtime_request_context = RuntimeRequestContext::from_correlation(&correlation) .with_response_cache_scope( tool.workspace_id.as_str().to_owned(), tool.agent_id.as_str().to_owned(), @@ -226,7 +232,7 @@ pub(super) async fn execute_approved_request( json!({ "error": { "code": runtime_error_code(&error), - "message": error.to_string(), + "message": safe_runtime_error_message(&error), } }), InvocationStatus::Error, @@ -240,7 +246,8 @@ pub(super) async fn execute_approved_request( state, &tool, InvocationRecord { - request_id: Some(&request_id), + request_id: Some(request_id), + trace_id: Some(correlation.trace_id().as_str()), tool_name: &tool.tool_name, status: invocation_status, level: invocation_level, diff --git a/crates/crank-community-mcp/src/jsonrpc.rs b/crates/crank-community-mcp/src/jsonrpc.rs index e330680..19c0f97 100644 --- a/crates/crank-community-mcp/src/jsonrpc.rs +++ b/crates/crank-community-mcp/src/jsonrpc.rs @@ -45,16 +45,35 @@ pub fn jsonrpc_result(id: Value, result: Value) -> Value { } pub fn jsonrpc_error(id: Value, code: i64, message: impl Into) -> Value { + let mut error = json!({ + "code": code, + "message": message.into() + }); + let (request_id, trace_id) = crank_observability::current_request_correlation(); + if request_id.is_some() && trace_id.is_some() { + error["data"] = correlated_error_data(json!({})); + } json!({ "jsonrpc": "2.0", "id": id, - "error": { - "code": code, - "message": message.into() - } + "error": error }) } +pub fn correlated_error_data(mut data: Value) -> Value { + if !data.is_object() { + data = json!({}); + } + let (request_id, trace_id) = crank_observability::current_request_correlation(); + if let Some(request_id) = request_id { + data["request_id"] = Value::String(request_id); + } + if let Some(trace_id) = trace_id { + data["trace_id"] = Value::String(trace_id); + } + data +} + pub fn negotiated_protocol_version(requested: &str) -> Option<&'static str> { SUPPORTED_PROTOCOL_VERSIONS .iter() diff --git a/crates/crank-community-mcp/src/rate_limit.rs b/crates/crank-community-mcp/src/rate_limit.rs index 90e7e93..4076dde 100644 --- a/crates/crank-community-mcp/src/rate_limit.rs +++ b/crates/crank-community-mcp/src/rate_limit.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::{ access::{bearer_token, hash_access_secret}, app::{AgentRoutePath, AppState}, - jsonrpc::request_id, + jsonrpc::{correlated_error_data, request_id}, transport::{ResponseMode, session_id_from_headers, transport_response}, }; @@ -39,7 +39,7 @@ pub(super) fn rate_limited_jsonrpc_response( "error": { "code": -32603, "message": "rate limit service unavailable", - "data": { "code": "rate_limit_unavailable" } + "data": correlated_error_data(json!({ "code": "rate_limit_unavailable" })) } }), response_mode, @@ -53,10 +53,10 @@ pub(super) fn rate_limited_jsonrpc_response( "error": { "code": -32029, "message": "request rate limit exceeded", - "data": { + "data": correlated_error_data(json!({ "code": "request_rate_limited", "retry_after_ms": rejection.retry_after_ms, - } + })) } }); diff --git a/crates/crank-community-mcp/src/request_context.rs b/crates/crank-community-mcp/src/request_context.rs index 820eb36..df7c768 100644 --- a/crates/crank-community-mcp/src/request_context.rs +++ b/crates/crank-community-mcp/src/request_context.rs @@ -1,33 +1,101 @@ use axum::{extract::Request, http::HeaderValue, middleware::Next, response::Response}; -use crank_observability::{RequestId, set_remote_trace_parent, with_request_correlation}; +use crank_core::{CorrelationContext, RequestId, TraceContext}; +use crank_observability::{set_remote_trace_parent, with_request_correlation}; use tracing::{Instrument, info_span}; use crate::transport::HEADER_X_REQUEST_ID; +const HEADER_X_TRACE_ID: axum::http::HeaderName = axum::http::HeaderName::from_static("x-trace-id"); + #[derive(Clone, Debug)] pub(super) struct RequestContext { - pub(super) request_id: String, + pub(super) correlation: CorrelationContext, +} + +impl RequestContext { + pub(super) fn request_id(&self) -> &str { + self.correlation.request_id().as_str() + } } pub(super) async fn apply_request_context(mut request: Request, next: Next) -> Response { - let request_id = RequestId::resolve_from_headers(request.headers()).into_string(); - let context = RequestContext { - request_id: request_id.clone(), - }; + let (request_id, remote_parent) = resolve_correlation(request.headers()); let span = info_span!( target: "crank::trace", "mcp.request", request_id = %request_id, + trace_id = tracing::field::Empty, ); - set_remote_trace_parent(&span, request.headers()); - request.extensions_mut().insert(context); + 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(request_id.clone(), async move { - let mut response = next.run(request).instrument(span).await; - if let Ok(value) = HeaderValue::from_str(&request_id) { - response.headers_mut().insert(HEADER_X_REQUEST_ID, 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; + if let Ok(value) = HeaderValue::from_str(context.correlation.request_id().as_str()) { + response.headers_mut().insert(HEADER_X_REQUEST_ID, value); + } + if let Ok(value) = HeaderValue::from_str(context.correlation.trace_id().as_str()) { + response.headers_mut().insert(HEADER_X_TRACE_ID, value); + } + response + }, + ) .await } + +fn resolve_correlation(headers: &axum::http::HeaderMap) -> (RequestId, Option) { + 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(HEADER_X_REQUEST_ID).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) { + let mut headers = axum::http::HeaderMap::new(); + if let Ok(value) = HeaderValue::from_str(context.traceparent()) { + headers.insert("traceparent", value); + set_remote_trace_parent(span, &headers); + } +} diff --git a/crates/crank-community-mcp/src/session.rs b/crates/crank-community-mcp/src/session.rs index 124d77b..e60a62d 100644 --- a/crates/crank-community-mcp/src/session.rs +++ b/crates/crank-community-mcp/src/session.rs @@ -130,7 +130,11 @@ pub struct PostgresTransportSessionStore { impl PostgresTransportSessionStore { pub async fn from_pool(pool: PgPool) -> Result { - apply_postgres_migrations(&pool).await?; + crank_registry::MigrationAuthority::require_current(&pool) + .await + .map_err(|error| SessionStoreError { + details: error.to_string(), + })?; Ok(Self { pool }) } @@ -413,124 +417,6 @@ impl TransportSessionStore for PostgresTransportSessionStore { } } -async fn apply_postgres_migrations(pool: &PgPool) -> Result<(), SessionStoreError> { - let mut transaction = pool.begin().await.map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - query("select pg_advisory_xact_lock($1)") - .bind(0x4352_414E_4B4D_4350_i64) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - query( - "create table if not exists __crank_mcp_migrations ( - version integer primary key, - checksum text not null, - applied_at timestamptz not null default now() - )", - ) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - let applied = query("select checksum from __crank_mcp_migrations where version = 1") - .fetch_optional(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - if let Some(row) = applied { - let checksum = row.get::("checksum"); - if checksum != "mcp-transport-sessions-v1" { - return Err(SessionStoreError { - details: format!("modified MCP migration version 1: {checksum}"), - }); - } - transaction - .commit() - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - return Ok(()); - } - - query( - "create table if not exists mcp_transport_sessions ( - id text primary key, - protocol_version text not null, - initialized boolean not null default false, - supports_elicitation boolean not null default false, - workspace_slug text not null, - agent_slug text not null, - created_at timestamptz not null, - updated_at timestamptz not null, - expires_at timestamptz null - )", - ) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - query("alter table mcp_transport_sessions add column if not exists supports_elicitation boolean not null default false") - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - query( - "alter table mcp_transport_sessions add column if not exists expires_at timestamptz null", - ) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - query( - "create index if not exists mcp_transport_sessions_workspace_agent_idx - on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc)", - ) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - query( - "create index if not exists mcp_transport_sessions_expires_at_idx - on mcp_transport_sessions(expires_at) - where expires_at is not null", - ) - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - query("insert into __crank_mcp_migrations (version, checksum) values (1, $1)") - .bind("mcp-transport-sessions-v1") - .execute(&mut *transaction) - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - transaction - .commit() - .await - .map_err(|error| SessionStoreError { - details: error.to_string(), - })?; - - Ok(()) -} - fn is_expired(session: &SessionState, now: OffsetDateTime) -> bool { session .expires_at diff --git a/crates/crank-community-mcp/src/tool_error.rs b/crates/crank-community-mcp/src/tool_error.rs index a1d224f..1a1964d 100644 --- a/crates/crank-community-mcp/src/tool_error.rs +++ b/crates/crank-community-mcp/src/tool_error.rs @@ -14,11 +14,13 @@ pub struct ToolErrorContract { #[serde(skip_serializing_if = "Option::is_none")] pub upstream_status: Option, pub request_id: String, + pub trace_id: String, } pub fn tool_error_contract_from_runtime( error: &RuntimeError, request_id: &str, + trace_id: &str, ) -> ToolErrorContract { let error_code = runtime_error_code(error); ToolErrorContract { @@ -29,6 +31,7 @@ pub fn tool_error_contract_from_runtime( suggested_action: suggested_action(error), upstream_status: upstream_status(error), request_id: request_id.to_owned(), + trace_id: trace_id.to_owned(), } } @@ -36,6 +39,7 @@ pub fn generic_tool_error_contract( error_code: &'static str, message: impl Into, request_id: &str, + trace_id: &str, recoverable: bool, suggested_action: Option<&'static str>, ) -> ToolErrorContract { @@ -47,6 +51,7 @@ pub fn generic_tool_error_contract( suggested_action, upstream_status: None, request_id: request_id.to_owned(), + trace_id: trace_id.to_owned(), } } @@ -64,7 +69,8 @@ pub fn tool_error_value(error: &ToolErrorContract) -> Value { "error_code": "runtime_error", "message": "Не удалось выполнить инструмент.", "recoverable": false, - "request_id": error.request_id + "request_id": error.request_id, + "trace_id": error.trace_id }) }) } @@ -109,7 +115,7 @@ fn upstream_status_code(status: u16) -> &'static str { } } -fn safe_runtime_error_message(error: &RuntimeError) -> String { +pub(crate) fn safe_runtime_error_message(error: &RuntimeError) -> String { match error { RuntimeError::Schema(_) => "Входные параметры не прошли проверку схемы.".to_owned(), RuntimeError::Mapping(_) => { diff --git a/crates/crank-community-mcp/src/tool_search.rs b/crates/crank-community-mcp/src/tool_search.rs index 29e83f0..50bbb00 100644 --- a/crates/crank-community-mcp/src/tool_search.rs +++ b/crates/crank-community-mcp/src/tool_search.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeSet, sync::Arc}; use axum::{http::StatusCode, response::Response}; -use crank_core::{ToolAccessMode, search_tool_catalog}; +use crank_core::{CorrelationContext, ToolAccessMode, search_tool_catalog}; use crank_registry::PublishedAgentCatalog; use crank_trace::{ErrorCategory, Stage, StageOutcome}; use serde::Deserialize; @@ -46,8 +46,9 @@ pub(super) async fn handle_catalog_tool_call( catalog: &PublishedAgentCatalog, tool_name: &str, arguments: Value, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Response { + let transport_request_id = transport_correlation.request_id().as_str(); match catalog.tool_selection_policy.mode { ToolAccessMode::Direct => { execute_catalog_tool( @@ -59,7 +60,7 @@ pub(super) async fn handle_catalog_tool_call( catalog, tool_name, arguments, - transport_request_id, + transport_correlation, ) .await } @@ -90,6 +91,7 @@ pub(super) async fn handle_catalog_tool_call( proxy.catalog_revision ), transport_request_id, + transport_correlation.trace_id().as_str(), true, Some( "Повторите search_tools и вызовите инструмент с новой версией каталога.", @@ -106,7 +108,7 @@ pub(super) async fn handle_catalog_tool_call( catalog, &proxy.name, proxy.arguments, - transport_request_id, + transport_correlation, ) .await } @@ -126,7 +128,7 @@ async fn execute_catalog_tool( catalog: &PublishedAgentCatalog, tool_name: &str, mut arguments: Value, - transport_request_id: &str, + transport_correlation: &CorrelationContext, ) -> Response { let resolve_span = Stage::McpToolsResolve.span(); let resolved = resolve_span.in_scope(|| resolve_generated_tool(&catalog.tools, tool_name)); @@ -158,7 +160,7 @@ async fn execute_catalog_tool( resolved, arguments, confirmation_token, - transport_request_id, + transport_correlation, ) .await } diff --git a/crates/crank-community-mcp/tests/integration/session.rs b/crates/crank-community-mcp/tests/integration/session.rs index 5dd7f4b..4c3bc4a 100644 --- a/crates/crank-community-mcp/tests/integration/session.rs +++ b/crates/crank-community-mcp/tests/integration/session.rs @@ -1,5 +1,5 @@ use crank_community_mcp::session::{PostgresTransportSessionStore, TransportSessionStore}; -use crank_registry::PostgresPoolConfig; +use crank_registry::{MigrationAuthority, PostgresPoolConfig}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -13,9 +13,15 @@ fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime { .unwrap() } +async fn migrate(database_url: &str) { + let pool = sqlx::PgPool::connect(database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); +} + #[tokio::test] async fn postgres_transport_sessions_survive_store_reconnect() { let database_url = crank_test_support::postgres_schema_url("test_mcp_transport").await; + migrate(&database_url).await; let connect_options = database_url.parse::().unwrap(); let pool_config = PostgresPoolConfig::default(); let store_a = PostgresTransportSessionStore::connect_with_options_and_pool_config( @@ -61,6 +67,7 @@ async fn postgres_transport_sessions_survive_store_reconnect() { #[tokio::test] async fn postgres_transport_sessions_evict_expired_rows_on_read() { let database_url = crank_test_support::postgres_schema_url("test_mcp_transport").await; + migrate(&database_url).await; let connect_options = database_url.parse::().unwrap(); let store = PostgresTransportSessionStore::connect_with_options_and_pool_config( connect_options.clone(), @@ -101,6 +108,7 @@ async fn postgres_transport_sessions_evict_expired_rows_on_read() { #[tokio::test] async fn postgres_transport_session_cleanup_removes_abandoned_expired_rows() { let database_url = crank_test_support::postgres_schema_url("test_mcp_cleanup").await; + migrate(&database_url).await; let store = PostgresTransportSessionStore::connect_with_options_and_pool_config( database_url.parse::().unwrap(), PostgresPoolConfig::default(), diff --git a/crates/crank-community-mcp/tests/unit/tool_error.rs b/crates/crank-community-mcp/tests/unit/tool_error.rs index c692063..de96b28 100644 --- a/crates/crank-community-mcp/tests/unit/tool_error.rs +++ b/crates/crank-community-mcp/tests/unit/tool_error.rs @@ -14,12 +14,14 @@ fn maps_upstream_429_to_recoverable_structured_tool_error() { }), }), "req-429", + "0af7651916cd43dd8448eb211c80319c", ); assert_eq!(contract.error_code, "upstream_rate_limited"); assert!(contract.recoverable); assert_eq!(contract.upstream_status, Some(429)); assert_eq!(contract.request_id, "req-429"); + assert_eq!(contract.trace_id, "0af7651916cd43dd8448eb211c80319c"); assert_eq!(contract.suggested_action, Some("Повторите запрос позже.")); assert!(!contract.message.contains("internal_trace")); } @@ -32,12 +34,14 @@ fn maps_mapping_error_to_non_recoverable_structured_tool_error() { reason: "expected string".to_owned(), }, "req-map", + "0af7651916cd43dd8448eb211c80319c", ); assert_eq!(contract.error_code, "runtime_error"); assert!(!contract.recoverable); assert_eq!(contract.upstream_status, None); assert_eq!(contract.request_id, "req-map"); + assert_eq!(contract.trace_id, "0af7651916cd43dd8448eb211c80319c"); assert_eq!( contract.suggested_action, Some("Проверьте параметры вызова инструмента.") diff --git a/crates/crank-config/Cargo.toml b/crates/crank-config/Cargo.toml new file mode 100644 index 0000000..aa5c55b --- /dev/null +++ b/crates/crank-config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "crank-config" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +url.workspace = true diff --git a/crates/crank-config/src/bin/crank-config-contract.rs b/crates/crank-config/src/bin/crank-config-contract.rs new file mode 100644 index 0000000..bf93d79 --- /dev/null +++ b/crates/crank-config/src/bin/crank-config-contract.rs @@ -0,0 +1,73 @@ +use std::{env, fs, path::Path}; + +use crank_config::render::{ + BEGIN_MARKER, DOC_BEGIN_MARKER, DOC_END_MARKER, END_MARKER, env_section, reference_section, + replace_marked, schema_json, +}; + +fn main() { + if let Err(error) = run() { + eprintln!("config contract check failed: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mode = env::args().nth(1).unwrap_or_else(|| "--check".to_owned()); + if !matches!(mode.as_str(), "--check" | "--write") { + return Err("expected --check or --write".to_owned()); + } + let write = mode == "--write"; + sync_file( + Path::new("docs/schemas/runtime-config.schema.json"), + schema_json(), + write, + )?; + for (path, production) in [ + (".env.example", false), + ("deploy/community/.env.example", true), + ("deploy/community/.env.images.example", true), + ] { + sync_marked( + Path::new(path), + BEGIN_MARKER, + END_MARKER, + &env_section(production), + write, + )?; + } + sync_marked( + Path::new("docs/runtime-config.md"), + DOC_BEGIN_MARKER, + DOC_END_MARKER, + &reference_section(), + write, + )?; + Ok(()) +} + +fn sync_marked( + path: &Path, + begin: &str, + end: &str, + replacement: &str, + write: bool, +) -> Result<(), String> { + let current = + fs::read_to_string(path).map_err(|_| format!("cannot read {}", path.display()))?; + let expected = replace_marked(¤t, begin, end, replacement) + .ok_or_else(|| format!("missing generated markers in {}", path.display()))?; + sync_file(path, expected, write) +} + +fn sync_file(path: &Path, expected: String, write: bool) -> Result<(), String> { + let current = fs::read_to_string(path).unwrap_or_default(); + if current == expected { + return Ok(()); + } + if write { + fs::write(path, expected).map_err(|_| format!("cannot write {}", path.display())) + } else { + Err(format!("{} is out of date", path.display())) + } +} diff --git a/crates/crank-config/src/debug.rs b/crates/crank-config/src/debug.rs new file mode 100644 index 0000000..4c6f33d --- /dev/null +++ b/crates/crank-config/src/debug.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use crate::{ + AdminProcessConfig, CacheSettings, DatabaseSettings, McpProcessConfig, MetricsSettings, + MigratorConfig, ObservabilitySettings, OtlpSettings, OutboundSettings, RuntimeSettings, +}; + +impl fmt::Debug for DatabaseSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DatabaseSettings") + .field("url", &self.url.as_ref().map(|_| "configured")) + .field("password", &self.password) + .field("pool", &self.pool) + .finish_non_exhaustive() + } +} +impl fmt::Debug for CacheSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CacheSettings") + .field("backend", &self.backend) + .field("url", &self.url.as_ref().map(|_| "configured")) + .finish() + } +} +impl fmt::Debug for OutboundSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutboundSettings") + .field("allowed_host_count", &self.allowed_hosts.len()) + .field("denied_host_count", &self.denied_hosts.len()) + .field("max_response_bytes", &self.max_response_bytes) + .finish() + } +} +impl fmt::Debug for RuntimeSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RuntimeSettings") + .field("master_key", &self.master_key) + .field("base_url", &self.base_url.as_ref().map(|_| "configured")) + .field("max_concurrent_unary", &self.max_concurrent_unary) + .field("max_concurrent_sessions", &self.max_concurrent_sessions) + .field("cache", &self.cache) + .field("outbound", &self.outbound) + .finish() + } +} +impl fmt::Debug for MetricsSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MetricsSettings") + .field("enabled", &self.enabled) + .field("loopback", &self.bind_addr.ip().is_loopback()) + .field( + "bearer_token", + &self.bearer_token.as_ref().map(|_| "configured"), + ) + .finish() + } +} +impl fmt::Debug for OtlpSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OtlpSettings") + .field("endpoint", &self.endpoint.as_ref().map(|_| "configured")) + .field( + "traces_endpoint", + &self.traces_endpoint.as_ref().map(|_| "configured"), + ) + .field("headers", &self.headers.as_ref().map(|_| "configured")) + .field( + "traces_headers", + &self.traces_headers.as_ref().map(|_| "configured"), + ) + .field("max_queue_size", &self.max_queue_size) + .field("max_export_batch_size", &self.max_export_batch_size) + .finish_non_exhaustive() + } +} +impl fmt::Debug for ObservabilitySettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ObservabilitySettings") + .field("environment", &"configured") + .field("log_filter", &"configured") + .field( + "sentry_dsn", + &self.sentry_dsn.as_ref().map(|_| "configured"), + ) + .field("metrics", &self.metrics) + .field("otlp", &self.otlp) + .finish() + } +} +impl fmt::Debug for AdminProcessConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AdminProcessConfig") + .field("database", &self.database) + .field("runtime", &self.runtime) + .field("observability", &self.observability) + .field("storage_root", &"configured") + .field("session_secret", &self.session_secret) + .field("password_pepper", &self.password_pepper) + .field("bootstrap_password", &self.bootstrap_password) + .finish_non_exhaustive() + } +} +impl fmt::Debug for McpProcessConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("McpProcessConfig") + .field("database", &self.database) + .field("runtime", &self.runtime) + .field("observability", &self.observability) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for MigratorConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MigratorConfig") + .field("database", &self.database) + .field("fingerprint", &self.fingerprint()) + .finish() + } +} diff --git a/crates/crank-config/src/diagnostic.rs b/crates/crank-config/src/diagnostic.rs new file mode 100644 index 0000000..662555c --- /dev/null +++ b/crates/crank-config/src/diagnostic.rs @@ -0,0 +1,206 @@ +use std::fmt; + +use serde::{Serialize, Serializer}; + +const MAX_DIAGNOSTICS: usize = 100; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum DiagnosticCode { + MissingRequired, + InvalidEncoding, + InvalidType, + OutOfRange, + UnknownField, + Conflict, + UnsafeCombination, + DeprecatedNoEffect, +} + +impl Serialize for DiagnosticCode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl DiagnosticCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::MissingRequired => "config.missing_required", + Self::InvalidEncoding => "config.invalid_encoding", + Self::InvalidType => "config.invalid_type", + Self::OutOfRange => "config.out_of_range", + Self::UnknownField => "config.unknown_field", + Self::Conflict => "config.conflict", + Self::UnsafeCombination => "config.unsafe_combination", + Self::DeprecatedNoEffect => "config.deprecated_no_effect", + } + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub field: String, + pub message_ru: &'static str, + pub message_en: &'static str, +} + +impl Diagnostic { + pub(crate) fn new(code: DiagnosticCode, field: impl Into) -> Self { + let (message_ru, message_en) = match code { + DiagnosticCode::MissingRequired => ( + "Обязательный параметр не настроен.", + "A required configuration field is not configured.", + ), + DiagnosticCode::InvalidEncoding => ( + "Параметр должен быть корректной строкой UTF-8.", + "The configuration field must be valid UTF-8.", + ), + DiagnosticCode::InvalidType => ( + "Параметр имеет недопустимый тип или формат.", + "The configuration field has an invalid type or format.", + ), + DiagnosticCode::OutOfRange => ( + "Параметр находится вне допустимых границ.", + "The configuration field is outside its allowed bounds.", + ), + DiagnosticCode::UnknownField => ( + "Неизвестный параметр в управляемом пространстве имён.", + "Unknown field in an owned configuration namespace.", + ), + DiagnosticCode::Conflict => ( + "Одновременно заданы конфликтующие источники конфигурации.", + "Conflicting configuration sources are set at the same time.", + ), + DiagnosticCode::UnsafeCombination => ( + "Комбинация параметров небезопасна или противоречива.", + "The configuration combination is unsafe or inconsistent.", + ), + DiagnosticCode::DeprecatedNoEffect => ( + "Устаревший параметр не имеет поддерживаемого эффекта.", + "The deprecated field has no supported effect.", + ), + }; + let mut field = field.into(); + if field.len() > 256 { + let mut boundary = 256; + while !field.is_char_boundary(boundary) { + boundary -= 1; + } + field.truncate(boundary); + } + Self { + code, + field, + message_ru, + message_en, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigError { + diagnostics: Vec, + omitted: usize, +} + +impl ConfigError { + pub fn single(code: DiagnosticCode, field: impl Into) -> Self { + Self::from_diagnostics(vec![Diagnostic::new(code, field)]) + } + + pub(crate) fn from_diagnostics(mut diagnostics: Vec) -> Self { + diagnostics.sort(); + diagnostics.dedup(); + let mut omitted = diagnostics.len().saturating_sub(MAX_DIAGNOSTICS); + diagnostics.truncate(MAX_DIAGNOSTICS); + while serialized_len(&diagnostics, omitted) > 65_536 && !diagnostics.is_empty() { + diagnostics.pop(); + omitted += 1; + } + Self { + diagnostics, + omitted, + } + } + + pub fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } + + pub fn omitted(&self) -> usize { + self.omitted + } + + pub fn to_json(&self) -> String { + #[derive(Serialize)] + struct Report<'a> { + diagnostics: &'a [Diagnostic], + omitted: usize, + } + serde_json::to_string(&Report { + diagnostics: &self.diagnostics, + omitted: self.omitted, + }) + .unwrap_or_else(|_| "{\"diagnostics\":[],\"omitted\":0}".to_owned()) + } +} + +fn serialized_len(diagnostics: &[Diagnostic], omitted: usize) -> usize { + #[derive(Serialize)] + struct Report<'a> { + diagnostics: &'a [Diagnostic], + omitted: usize, + } + serde_json::to_vec(&Report { + diagnostics, + omitted, + }) + .map_or(usize::MAX, |value| value.len()) +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, diagnostic) in self.diagnostics.iter().enumerate() { + if index > 0 { + formatter.write_str("; ")?; + } + write!( + formatter, + "{} field={} ru={} en={}", + diagnostic.code.as_str(), + diagnostic.field, + diagnostic.message_ru, + diagnostic.message_en + )?; + } + if self.omitted > 0 { + write!(formatter, "; diagnostics_omitted={}", self.omitted)?; + } + Ok(()) + } +} + +impl std::error::Error for ConfigError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_fields_and_worst_case_json_remain_bounded() { + let field = format!("{}{}", "\\\"".repeat(120), "💣".repeat(100)); + let diagnostics = (0..200) + .map(|index| Diagnostic::new(DiagnosticCode::InvalidType, format!("{index}:{field}"))) + .collect(); + let error = ConfigError::from_diagnostics(diagnostics); + let json = error.to_json(); + assert!(json.len() <= 65_536); + assert!(error.omitted() > 0); + assert!(serde_json::from_str::(&json).is_ok()); + assert!(json.contains("config.invalid_type")); + } +} diff --git a/crates/crank-config/src/fingerprint.rs b/crates/crank-config/src/fingerprint.rs new file mode 100644 index 0000000..e3bcb34 --- /dev/null +++ b/crates/crank-config/src/fingerprint.rs @@ -0,0 +1,11 @@ +use sha2::{Digest, Sha256}; + +pub(crate) fn sha256_hex(parts: impl IntoIterator) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"crank-config-fingerprint-v1\0"); + for part in parts { + hasher.update(part.len().to_le_bytes()); + hasher.update(part.as_bytes()); + } + format!("{:x}", hasher.finalize()) +} diff --git a/crates/crank-config/src/lib.rs b/crates/crank-config/src/lib.rs new file mode 100644 index 0000000..8a6beb3 --- /dev/null +++ b/crates/crank-config/src/lib.rs @@ -0,0 +1,25 @@ +//! Typed bootstrap configuration contract for Crank processes. + +mod debug; +mod diagnostic; +mod fingerprint; +mod migrator; +mod process; +pub mod render; +mod schema; +mod source; +mod validation; +mod value; + +pub use diagnostic::{ConfigError, Diagnostic, DiagnosticCode}; +pub use migrator::{MigratorConfig, parse_migrator}; +pub use process::{ + AdminProcessConfig, CacheBackend, CacheSettings, DatabaseSettings, DeprecationRecord, + EffectiveConfig, McpProcessConfig, MetricsSettings, ObservabilitySettings, OtlpSettings, + OutboundSettings, PoolSettings, ProcessKind, RateLimitSettings, RuntimeSettings, parse_process, +}; +pub use schema::{ + FieldMode, FieldSpec, ProcessScope, Sensitivity, deployment_field_registry, field_registry, +}; +pub use source::ConfigSource; +pub use value::SecretString; diff --git a/crates/crank-config/src/migrator.rs b/crates/crank-config/src/migrator.rs new file mode 100644 index 0000000..7b44110 --- /dev/null +++ b/crates/crank-config/src/migrator.rs @@ -0,0 +1,47 @@ +use crate::{ + ConfigError, ConfigSource, DatabaseSettings, DeprecationRecord, fingerprint::sha256_hex, + process::parse_database_source, +}; + +#[derive(Clone)] +pub struct MigratorConfig { + pub database: DatabaseSettings, + fingerprint: String, + deprecations: Vec, +} + +impl MigratorConfig { + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + pub fn deprecations(&self) -> &[DeprecationRecord] { + &self.deprecations + } +} + +pub fn parse_migrator(source: ConfigSource) -> Result { + let (database, deprecations) = parse_database_source(source.retain_for_migrator())?; + let fingerprint = sha256_hex([ + "schema=crank-config-migrator-v1".to_owned(), + format!("url={}", database.url.is_some()), + format!("host={}", database.host.to_ascii_lowercase()), + format!("port={}", database.port), + format!("database={}", database.database), + format!("username={}", database.username), + format!("password={}", database.password.is_configured()), + format!( + "pool={}:{}:{}:{}:{}", + database.pool.max_connections, + database.pool.min_connections, + database.pool.acquire_timeout_ms, + database.pool.idle_timeout_ms, + database.pool.max_lifetime_ms + ), + ]); + Ok(MigratorConfig { + database, + fingerprint, + deprecations, + }) +} diff --git a/crates/crank-config/src/process.rs b/crates/crank-config/src/process.rs new file mode 100644 index 0000000..1ae1b19 --- /dev/null +++ b/crates/crank-config/src/process.rs @@ -0,0 +1,1000 @@ +use std::{collections::BTreeMap, fmt, net::SocketAddr, path::PathBuf}; + +use url::Url; + +use crate::{ + ConfigError, ConfigSource, Diagnostic, DiagnosticCode, ProcessScope, SecretString, + deployment_field_registry, field_registry, + fingerprint::sha256_hex, + schema::semantic_path_for, + validation::{valid_database_host, valid_database_identifier, valid_percent_encoding}, +}; + +const MAX_ENV_VALUE_BYTES: usize = 8_192; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessKind { + AdminApi, + McpServer, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeprecationRecord { + pub field: &'static str, + pub source_class: &'static str, + pub replacement: &'static str, + pub removal_window: &'static str, +} + +impl ProcessKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::AdminApi => "admin-api", + Self::McpServer => "mcp-server", + } + } + + const fn scope(self) -> ProcessScope { + match self { + Self::AdminApi => ProcessScope::AdminApi, + Self::McpServer => ProcessScope::McpServer, + } + } +} + +#[derive(Clone, Debug)] +pub struct PoolSettings { + pub max_connections: u32, + pub min_connections: u32, + pub acquire_timeout_ms: u64, + pub idle_timeout_ms: u64, + pub max_lifetime_ms: u64, +} + +#[derive(Clone)] +pub struct DatabaseSettings { + pub url: Option, + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + pub password: SecretString, + pub pool: PoolSettings, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CacheBackend { + Memory, + Valkey, + Redis, +} + +#[derive(Clone)] +pub struct CacheSettings { + pub backend: CacheBackend, + pub url: Option, +} + +#[derive(Clone)] +pub struct OutboundSettings { + pub allowed_hosts: Vec, + pub denied_hosts: Vec, + pub max_response_bytes: usize, +} + +#[derive(Clone)] +pub struct RuntimeSettings { + pub master_key: SecretString, + pub base_url: Option, + pub max_concurrent_unary: usize, + pub max_concurrent_sessions: usize, + pub cache: CacheSettings, + pub outbound: OutboundSettings, +} + +#[derive(Clone, Debug)] +pub struct RateLimitSettings { + pub requests_per_second: u32, + pub burst: u32, +} + +#[derive(Clone)] +pub struct MetricsSettings { + pub enabled: bool, + pub bind_addr: SocketAddr, + pub bearer_token: Option, +} + +#[derive(Clone, Default)] +pub struct OtlpSettings { + pub endpoint: Option, + pub traces_endpoint: Option, + pub protocol: Option, + pub traces_protocol: Option, + pub timeout: Option, + pub traces_timeout: Option, + pub headers: Option, + pub traces_headers: Option, + pub max_queue_size: usize, + pub max_export_batch_size: usize, + pub schedule_delay: String, + pub export_timeout: String, +} + +#[derive(Clone)] +pub struct ObservabilitySettings { + pub environment: String, + pub log_filter: String, + pub sentry_dsn: Option, + pub metrics: MetricsSettings, + pub otlp: OtlpSettings, +} + +#[derive(Clone)] +pub struct AdminProcessConfig { + pub database: DatabaseSettings, + pub runtime: RuntimeSettings, + pub observability: ObservabilitySettings, + pub bind_addr: SocketAddr, + pub storage_root: PathBuf, + pub rate_limit: RateLimitSettings, + pub invocation_log_retention_days: i64, + pub session_secret: SecretString, + pub password_pepper: SecretString, + pub session_ttl_hours: i64, + pub trust_forwarded_headers: bool, + pub bootstrap_email: String, + pub bootstrap_password: SecretString, + pub bootstrap_display_name: String, + pub demo_seed: bool, +} + +#[derive(Clone)] +pub struct McpProcessConfig { + pub database: DatabaseSettings, + pub runtime: RuntimeSettings, + pub observability: ObservabilitySettings, + pub bind_addr: SocketAddr, + pub refresh_ms: u64, + pub rate_limit: RateLimitSettings, +} + +#[derive(Clone)] +enum Projection { + Admin(AdminProcessConfig), + Mcp(McpProcessConfig), +} + +#[derive(Clone)] +pub struct EffectiveConfig { + kind: ProcessKind, + fingerprint: String, + projection: Projection, + deprecations: Vec, +} + +impl EffectiveConfig { + pub fn admin(&self) -> Option<&AdminProcessConfig> { + match &self.projection { + Projection::Admin(config) => Some(config), + Projection::Mcp(_) => None, + } + } + + pub fn mcp(&self) -> Option<&McpProcessConfig> { + match &self.projection { + Projection::Mcp(config) => Some(config), + Projection::Admin(_) => None, + } + } + + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + pub fn deprecations(&self) -> &[DeprecationRecord] { + &self.deprecations + } +} + +impl fmt::Debug for EffectiveConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EffectiveConfig") + .field("process", &self.kind.as_str()) + .field("fingerprint", &self.fingerprint) + .finish() + } +} + +struct Parser<'a> { + kind: ProcessKind, + values: &'a BTreeMap, + diagnostics: Vec, + deprecations: Vec, +} + +impl<'a> Parser<'a> { + fn new(kind: ProcessKind, values: &'a BTreeMap) -> Self { + Self { + kind, + values, + diagnostics: Vec::new(), + deprecations: Vec::new(), + } + } + + fn check_source(&mut self) { + let known = field_registry() + .iter() + .map(|field| field.env_name) + .collect::>(); + let deployment_only = deployment_field_registry() + .iter() + .copied() + .collect::>(); + for (name, value) in self.values { + let owned = name.starts_with("CRANK_") + || name.starts_with("POSTGRES_") + || name.starts_with("OTEL_"); + if !owned && !known.contains(name.as_str()) { + continue; + } + if deployment_only.contains(name.as_str()) { + continue; + } + let spec = field_registry().iter().find(|field| field.env_name == name); + if owned && spec.is_none() { + self.push(DiagnosticCode::UnknownField, "environment.unknown"); + continue; + } + if let Some(spec) = spec { + let applicable = match spec.process { + ProcessScope::Shared => true, + ProcessScope::AdminApi => self.kind == ProcessKind::AdminApi, + ProcessScope::McpServer => self.kind == ProcessKind::McpServer, + }; + if !applicable { + self.push(DiagnosticCode::UnknownField, spec.semantic_path); + continue; + } + if value.len() > MAX_ENV_VALUE_BYTES || value.chars().any(char::is_control) { + self.push(DiagnosticCode::OutOfRange, spec.semantic_path); + } + } + } + if self + .values + .get("CRANK_CACHE_DEFAULT_TTL_MS") + .is_some_and(|value| !value.is_empty()) + { + self.push( + DiagnosticCode::DeprecatedNoEffect, + "CRANK_CACHE_DEFAULT_TTL_MS", + ); + } + } + + fn push(&mut self, code: DiagnosticCode, field: impl Into) { + let field = field.into(); + let semantic = field_registry() + .iter() + .find(|spec| spec.env_name == field) + .map_or(field.as_str(), |spec| spec.semantic_path); + self.diagnostics.push(Diagnostic::new(code, semantic)); + } + + fn optional(&mut self, name: &'static str) -> Option { + self.values.get(name).and_then(|value| { + if value.is_empty() { + None + } else if value.len() > MAX_ENV_VALUE_BYTES || value.chars().any(char::is_control) { + self.push(DiagnosticCode::OutOfRange, name); + None + } else if value.trim().is_empty() { + self.push(DiagnosticCode::InvalidType, name); + None + } else { + Some(value.clone()) + } + }) + } + + fn string(&mut self, name: &'static str, default: Option<&str>) -> String { + match self + .optional(name) + .or_else(|| default.map(ToOwned::to_owned)) + { + Some(value) => value, + None => { + self.push(DiagnosticCode::MissingRequired, name); + String::new() + } + } + } + + fn secret(&mut self, name: &'static str, default: Option<&str>) -> SecretString { + SecretString::new(self.string(name, default)) + } + + fn optional_secret(&mut self, name: &'static str) -> Option { + self.optional(name).map(SecretString::new) + } + + fn number(&mut self, name: &'static str) -> u64 { + let spec = field_registry() + .iter() + .find(|field| field.env_name == name) + .expect("registered numeric field"); + let default = spec + .default + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let min = spec.minimum.expect("numeric minimum"); + let max = spec.maximum.expect("numeric maximum"); + let Some(raw) = self.values.get(name) else { + return default; + }; + if raw.is_empty() || raw.trim() != raw { + self.push(DiagnosticCode::InvalidType, name); + return default; + } + match raw.parse::() { + Ok(value) if (min..=max).contains(&value) => value, + Ok(_) => { + self.push(DiagnosticCode::OutOfRange, name); + default + } + Err(_) => { + self.push(DiagnosticCode::InvalidType, name); + default + } + } + } + + fn boolean(&mut self, name: &'static str) -> bool { + let default = field_registry() + .iter() + .find(|field| field.env_name == name) + .and_then(|field| field.default) + .and_then(|value| value.parse().ok()) + .unwrap_or(false); + let Some(raw) = self.values.get(name) else { + return default; + }; + match raw.to_ascii_lowercase().as_str() { + "true" | "1" => true, + "false" | "0" => false, + "yes" | "on" => { + self.deprecations.push(DeprecationRecord { + field: semantic_path_for(name), + source_class: "canonical_env_compatibility_spelling", + replacement: "true", + removal_window: "after-0.3", + }); + true + } + "no" | "off" => { + self.deprecations.push(DeprecationRecord { + field: semantic_path_for(name), + source_class: "canonical_env_compatibility_spelling", + replacement: "false", + removal_window: "after-0.3", + }); + false + } + _ => { + self.push(DiagnosticCode::InvalidType, name); + default + } + } + } + + fn socket(&mut self, name: &'static str) -> SocketAddr { + let default = field_registry() + .iter() + .find(|field| field.env_name == name) + .and_then(|field| field.default) + .expect("registered socket default"); + let raw = self.string(name, Some(default)); + match raw.parse::() { + Ok(value) if value.port() != 0 => value, + Ok(_) => { + self.push(DiagnosticCode::OutOfRange, name); + default.parse().expect("static socket default") + } + Err(_) => { + self.push(DiagnosticCode::InvalidType, name); + default.parse().expect("static socket default") + } + } + } + + fn absolute_path(&mut self, name: &'static str, default: &'static str) -> PathBuf { + let raw = self.string(name, Some(default)); + let path = PathBuf::from(&raw); + if raw.len() > 4096 || !path.is_absolute() { + self.push(DiagnosticCode::InvalidType, name); + return PathBuf::from(default); + } + path + } + + fn url(&mut self, name: &'static str) -> Option { + let raw = self.optional(name)?; + match Url::parse(&raw) { + Ok(url) + if matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() => + { + Some(raw) + } + _ => { + self.push(DiagnosticCode::InvalidType, name); + None + } + } + } + + fn duration(&mut self, name: &'static str, default: Option) -> Option { + let raw = match self.values.get(name) { + Some(raw) if !raw.is_empty() => raw, + _ => return default.map(|value| value.to_string()), + }; + if raw.trim() != raw { + self.push(DiagnosticCode::InvalidType, name); + return default.map(|value| value.to_string()); + } + match raw.parse::() { + Ok(value) if (1..=300_000).contains(&value) => Some(value.to_string()), + Ok(_) => { + self.push(DiagnosticCode::OutOfRange, name); + default.map(|value| value.to_string()) + } + Err(_) => { + self.push(DiagnosticCode::InvalidType, name); + default.map(|value| value.to_string()) + } + } + } + + fn headers(&mut self, name: &'static str) -> Option { + let raw = self.optional(name)?; + let valid = raw + .split(',') + .filter(|item| !item.trim().is_empty()) + .all(|item| { + item.split_once('=').is_some_and(|(header, value)| { + let header = header.trim(); + !header.is_empty() + && header.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + && !value.trim().is_empty() + && valid_percent_encoding(value) + }) + }); + if !valid { + self.push(DiagnosticCode::InvalidType, name); + None + } else { + Some(SecretString::new(raw)) + } + } + + fn host_list(&mut self, name: &'static str) -> Vec { + let Some(raw) = self.optional(name) else { + return Vec::new(); + }; + if raw.len() > 16_384 { + self.push(DiagnosticCode::OutOfRange, name); + return Vec::new(); + } + let mut items = Vec::new(); + for item in raw.split(',') { + let item = item.trim().to_ascii_lowercase(); + let base = item.strip_prefix("*.").unwrap_or(&item); + let valid_ip = !item.starts_with("*.") && base.parse::().is_ok(); + if item.is_empty() + || item.len() > 253 + || item.contains('/') + || item.contains(char::is_whitespace) + || base.is_empty() + || (!valid_ip && base.contains(':')) + || (item.starts_with("*.") && base.parse::().is_ok()) + { + self.push(DiagnosticCode::InvalidType, name); + continue; + } + if !items.contains(&item) { + items.push(item); + } + } + if items.len() > 256 { + self.push(DiagnosticCode::OutOfRange, name); + items.truncate(256); + } + items + } +} + +fn parse_database(parser: &mut Parser<'_>) -> DatabaseSettings { + let database_url = parser.optional_secret("CRANK_DATABASE_URL"); + if database_url.is_some() { + parser.deprecations.push(DeprecationRecord { + field: semantic_path_for("CRANK_DATABASE_URL"), + source_class: "legacy_alias", + replacement: "POSTGRES_HOST/PORT/DB/USER/PASSWORD", + removal_window: "after-0.3", + }); + } + let decomposed_defaults = [ + ("POSTGRES_HOST", "postgres"), + ("POSTGRES_PORT", "5432"), + ("POSTGRES_DB", "crank"), + ("POSTGRES_USER", "crank"), + ("POSTGRES_PASSWORD", "crank"), + ]; + if database_url.is_some() + && decomposed_defaults.iter().any(|(name, default)| { + parser + .values + .get(*name) + .is_some_and(|value| !value.is_empty() && value != default) + }) + { + parser.push(DiagnosticCode::Conflict, "database.source"); + } + if let Some(url) = database_url.as_ref() { + let valid = Url::parse(url.expose_secret()).ok().is_some_and(|url| { + matches!(url.scheme(), "postgres" | "postgresql") + && url.host_str().is_some() + && !url.path().trim_matches('/').is_empty() + && url.query_pairs().all(|(name, value)| { + name != "sslmode" + || matches!( + value.as_ref(), + "disable" + | "allow" + | "prefer" + | "require" + | "verify-ca" + | "verify-full" + ) + }) + }); + if !valid { + parser.push(DiagnosticCode::InvalidType, "CRANK_DATABASE_URL"); + } + } + let database = DatabaseSettings { + url: database_url, + host: parser.string("POSTGRES_HOST", Some("postgres")), + port: parser.number("POSTGRES_PORT") as u16, + database: parser.string("POSTGRES_DB", Some("crank")), + username: parser.string("POSTGRES_USER", Some("crank")), + password: parser.secret("POSTGRES_PASSWORD", Some("crank")), + pool: PoolSettings { + max_connections: parser.number("POSTGRES_MAX_CONNECTIONS") as u32, + min_connections: parser.number("POSTGRES_MIN_CONNECTIONS") as u32, + acquire_timeout_ms: parser.number("POSTGRES_ACQUIRE_TIMEOUT_MS"), + idle_timeout_ms: parser.number("POSTGRES_IDLE_TIMEOUT_MS"), + max_lifetime_ms: parser.number("POSTGRES_MAX_LIFETIME_MS"), + }, + }; + if !valid_database_host(&database.host) { + parser.push(DiagnosticCode::InvalidType, "POSTGRES_HOST"); + } + for (name, value) in [ + ("POSTGRES_DB", database.database.as_str()), + ("POSTGRES_USER", database.username.as_str()), + ] { + if !valid_database_identifier(value) { + parser.push(DiagnosticCode::InvalidType, name); + } + } + if database.pool.min_connections > database.pool.max_connections { + parser.push( + DiagnosticCode::UnsafeCombination, + "database.pool.min_connections", + ); + } + database +} + +pub(crate) fn parse_database_source( + source: ConfigSource, +) -> Result<(DatabaseSettings, Vec), ConfigError> { + let values = source.values(); + let mut parser = Parser::new(ProcessKind::AdminApi, values); + parser.check_source(); + let database = parse_database(&mut parser); + if !parser.diagnostics.is_empty() { + return Err(ConfigError::from_diagnostics(parser.diagnostics)); + } + Ok((database, parser.deprecations)) +} + +pub fn parse_process( + kind: ProcessKind, + source: ConfigSource, +) -> Result { + let values = source.values(); + let mut parser = Parser::new(kind, values); + parser.check_source(); + + let database = parse_database(&mut parser); + + let cache_backend = match parser + .values + .get("CRANK_CACHE_BACKEND") + .map(String::as_str) + .unwrap_or("memory") + .to_ascii_lowercase() + .as_str() + { + "memory" => CacheBackend::Memory, + "valkey" => CacheBackend::Valkey, + "redis" => CacheBackend::Redis, + _ => { + parser.push(DiagnosticCode::InvalidType, "CRANK_CACHE_BACKEND"); + CacheBackend::Memory + } + }; + if cache_backend == CacheBackend::Redis { + parser.deprecations.push(DeprecationRecord { + field: semantic_path_for("CRANK_CACHE_BACKEND"), + source_class: "canonical_env_compatibility_value", + replacement: "valkey", + removal_window: "after-0.3", + }); + } + let cache_url = parser.optional_secret("CRANK_CACHE_URL"); + if let Some(url) = cache_url.as_ref() + && Url::parse(url.expose_secret()).ok().is_none_or(|url| { + !matches!(url.scheme(), "redis" | "rediss") + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + }) + { + parser.push(DiagnosticCode::InvalidType, "CRANK_CACHE_URL"); + } + if matches!(cache_backend, CacheBackend::Valkey | CacheBackend::Redis) && cache_url.is_none() { + parser.push(DiagnosticCode::MissingRequired, "CRANK_CACHE_URL"); + } + if cache_backend == CacheBackend::Memory && cache_url.is_some() { + parser.push(DiagnosticCode::Conflict, "cache.url"); + } + + let runtime = RuntimeSettings { + master_key: parser.secret("CRANK_MASTER_KEY", None), + base_url: parser.url("CRANK_BASE_URL").or_else(|| { + field_registry() + .iter() + .find(|field| field.env_name == "CRANK_BASE_URL") + .and_then(|field| field.default_for(kind.scope())) + .map(ToOwned::to_owned) + }), + max_concurrent_unary: parser.number("CRANK_RUNTIME_MAX_CONCURRENT_UNARY") as usize, + max_concurrent_sessions: if kind == ProcessKind::McpServer { + parser.number("CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS") as usize + } else { + 16 + }, + cache: CacheSettings { + backend: cache_backend, + url: cache_url, + }, + outbound: OutboundSettings { + allowed_hosts: parser.host_list("CRANK_OUTBOUND_ALLOWED_HOSTS"), + denied_hosts: parser.host_list("CRANK_OUTBOUND_DENIED_HOSTS"), + max_response_bytes: parser.number("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") as usize, + }, + }; + + let metrics_bind_name = match kind { + ProcessKind::AdminApi => "CRANK_ADMIN_METRICS_BIND", + ProcessKind::McpServer => "CRANK_MCP_METRICS_BIND", + }; + let metrics = MetricsSettings { + enabled: parser.boolean("CRANK_METRICS_ENABLED"), + bind_addr: parser.socket(metrics_bind_name), + bearer_token: parser.optional_secret("CRANK_METRICS_BEARER_TOKEN"), + }; + if metrics.enabled && !metrics.bind_addr.ip().is_loopback() && metrics.bearer_token.is_none() { + parser.push( + DiagnosticCode::UnsafeCombination, + "observability.metrics.bearer_token", + ); + } + + let otlp = OtlpSettings { + endpoint: parser.url("OTEL_EXPORTER_OTLP_ENDPOINT"), + traces_endpoint: parser.url("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), + protocol: parser.optional("OTEL_EXPORTER_OTLP_PROTOCOL").or_else(|| { + field_registry() + .iter() + .find(|field| field.env_name == "OTEL_EXPORTER_OTLP_PROTOCOL") + .and_then(|field| field.default) + .map(ToOwned::to_owned) + }), + traces_protocol: parser.optional("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"), + timeout: parser.duration( + "OTEL_EXPORTER_OTLP_TIMEOUT", + field_registry() + .iter() + .find(|field| field.env_name == "OTEL_EXPORTER_OTLP_TIMEOUT") + .and_then(|field| field.default) + .and_then(|value| value.parse().ok()), + ), + traces_timeout: parser.duration("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", None), + headers: parser.headers("OTEL_EXPORTER_OTLP_HEADERS"), + traces_headers: parser.headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS"), + max_queue_size: parser.number("OTEL_BSP_MAX_QUEUE_SIZE") as usize, + max_export_batch_size: parser.number("OTEL_BSP_MAX_EXPORT_BATCH_SIZE") as usize, + schedule_delay: parser + .duration("OTEL_BSP_SCHEDULE_DELAY", Some(5_000)) + .expect("duration default"), + export_timeout: parser + .duration("OTEL_BSP_EXPORT_TIMEOUT", Some(30_000)) + .expect("duration default"), + }; + if otlp.max_export_batch_size > otlp.max_queue_size { + parser.push( + DiagnosticCode::UnsafeCombination, + "observability.otlp.max_export_batch_size", + ); + } + for (name, protocol) in [ + ("OTEL_EXPORTER_OTLP_PROTOCOL", otlp.protocol.as_deref()), + ( + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + otlp.traces_protocol.as_deref(), + ), + ] { + if protocol.is_some_and(|value| value != "http/protobuf") { + parser.push(DiagnosticCode::InvalidType, name); + } + } + + let observability = ObservabilitySettings { + environment: parser.string("CRANK_ENVIRONMENT", Some("development")), + log_filter: parser.string( + "CRANK_LOG_LEVEL", + field_registry() + .iter() + .find(|field| field.env_name == "CRANK_LOG_LEVEL") + .and_then(|field| field.default_for(kind.scope())), + ), + sentry_dsn: parser.optional_secret("CRANK_SENTRY_DSN"), + metrics, + otlp, + }; + if observability.environment.len() > 64 + || !observability + .environment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+')) + { + parser.push(DiagnosticCode::InvalidType, "CRANK_ENVIRONMENT"); + } + if let Some(dsn) = observability.sentry_dsn.as_ref() + && Url::parse(dsn.expose_secret()) + .ok() + .is_none_or(|url| !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()) + { + parser.push(DiagnosticCode::InvalidType, "CRANK_SENTRY_DSN"); + } + + let projection = match kind { + ProcessKind::AdminApi => { + let rps = parser.number("CRANK_ADMIN_RATE_LIMIT_RPS") as u32; + let burst = parser.number("CRANK_ADMIN_RATE_LIMIT_BURST") as u32; + if burst < rps { + parser.push(DiagnosticCode::UnsafeCombination, "admin.rate_limit.burst"); + } + Projection::Admin(AdminProcessConfig { + database, + runtime, + observability, + bind_addr: parser.socket("CRANK_ADMIN_BIND"), + storage_root: parser.absolute_path("CRANK_STORAGE_ROOT", "/var/lib/crank/storage"), + rate_limit: RateLimitSettings { + requests_per_second: rps, + burst, + }, + invocation_log_retention_days: parser.number("CRANK_INVOCATION_LOG_RETENTION_DAYS") + as i64, + session_secret: parser.secret("CRANK_SESSION_SECRET", None), + password_pepper: parser.secret("CRANK_PASSWORD_PEPPER", None), + session_ttl_hours: parser.number("CRANK_SESSION_TTL_HOURS") as i64, + trust_forwarded_headers: parser.boolean("CRANK_TRUST_FORWARDED_HEADERS"), + bootstrap_email: parser.string("CRANK_BOOTSTRAP_ADMIN_EMAIL", None), + bootstrap_password: parser.secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD", None), + bootstrap_display_name: parser + .string("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", Some("Crank Owner")), + demo_seed: parser.boolean("CRANK_DEMO_SEED"), + }) + } + ProcessKind::McpServer => { + let rps = parser.number("CRANK_MCP_RATE_LIMIT_RPS") as u32; + let burst = parser.number("CRANK_MCP_RATE_LIMIT_BURST") as u32; + if burst < rps { + parser.push(DiagnosticCode::UnsafeCombination, "mcp.rate_limit.burst"); + } + Projection::Mcp(McpProcessConfig { + database, + runtime, + observability, + bind_addr: parser.socket("CRANK_MCP_BIND"), + refresh_ms: parser.number("CRANK_MCP_REFRESH_MS"), + rate_limit: RateLimitSettings { + requests_per_second: rps, + burst, + }, + }) + } + }; + + if !parser.diagnostics.is_empty() { + return Err(ConfigError::from_diagnostics(parser.diagnostics)); + } + + let fingerprint_parts = fingerprint_parts(kind, &projection); + Ok(EffectiveConfig { + kind, + fingerprint: sha256_hex(fingerprint_parts), + projection, + deprecations: parser.deprecations, + }) +} + +fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec { + let (database, runtime, observability, process_parts): ( + &DatabaseSettings, + &RuntimeSettings, + &ObservabilitySettings, + Vec, + ) = match projection { + Projection::Admin(config) => ( + &config.database, + &config.runtime, + &config.observability, + vec![ + format!("bind={}", config.bind_addr), + format!( + "rate={}:{}", + config.rate_limit.requests_per_second, config.rate_limit.burst + ), + format!("retention={}", config.invocation_log_retention_days), + format!("session_ttl={}", config.session_ttl_hours), + format!("trusted={}", config.trust_forwarded_headers), + format!("demo={}", config.demo_seed), + "storage=path-configured".to_owned(), + format!("session_secret={}", config.session_secret.is_configured()), + format!("pepper={}", config.password_pepper.is_configured()), + format!( + "bootstrap_password={}", + config.bootstrap_password.is_configured() + ), + ], + ), + Projection::Mcp(config) => ( + &config.database, + &config.runtime, + &config.observability, + vec![ + format!("bind={}", config.bind_addr), + format!("refresh={}", config.refresh_ms), + format!( + "rate={}:{}", + config.rate_limit.requests_per_second, config.rate_limit.burst + ), + ], + ), + }; + let (db_host, db_port, db_name, db_user, db_sslmode) = database + .url + .as_ref() + .and_then(|secret| { + Url::parse(secret.expose_secret()).ok().map(|url| { + ( + url.host_str().unwrap_or_default().to_ascii_lowercase(), + url.port().unwrap_or(5432), + url.path().trim_start_matches('/').to_owned(), + url.username().to_owned(), + url.query_pairs() + .find(|(name, _)| name == "sslmode") + .map_or_else(|| "default".to_owned(), |(_, value)| value.into_owned()), + ) + }) + }) + .unwrap_or_else(|| { + ( + database.host.to_ascii_lowercase(), + database.port, + database.database.clone(), + database.username.clone(), + "default".to_owned(), + ) + }); + let mut allowed = runtime.outbound.allowed_hosts.clone(); + allowed.sort(); + let mut denied = runtime.outbound.denied_hosts.clone(); + denied.sort(); + let mut parts = vec![ + "schema=crank-config-v1".to_owned(), + format!("process={}", kind.as_str()), + format!("database={db_host}:{db_port}/{db_name}:{db_user}:{db_sslmode}"), + format!("database_password={}", database.password.is_configured()), + format!( + "pool={}:{}:{}:{}:{}", + database.pool.max_connections, + database.pool.min_connections, + database.pool.acquire_timeout_ms, + database.pool.idle_timeout_ms, + database.pool.max_lifetime_ms + ), + format!("master_key={}", runtime.master_key.is_configured()), + format!( + "base_url={}", + runtime.base_url.as_deref().unwrap_or("unconfigured") + ), + format!("unary={}", runtime.max_concurrent_unary), + format!("sessions={}", runtime.max_concurrent_sessions), + format!( + "cache={:?}:{}", + runtime.cache.backend, + runtime.cache.url.is_some() + ), + format!("allowed={}", allowed.join(",")), + format!("denied={}", denied.join(",")), + format!("max_response={}", runtime.outbound.max_response_bytes), + format!("environment={}", observability.environment), + format!("log_filter={}", observability.log_filter), + format!("sentry={}", observability.sentry_dsn.is_some()), + format!( + "metrics={}:{}:{}", + observability.metrics.enabled, + observability.metrics.bind_addr, + observability.metrics.bearer_token.is_some() + ), + format!( + "otlp={}:{}:{}:{}", + observability.otlp.endpoint.is_some(), + observability.otlp.traces_endpoint.is_some(), + observability.otlp.headers.is_some(), + observability.otlp.traces_headers.is_some() + ), + format!( + "otlp_limits={}:{}:{}:{}", + observability.otlp.max_queue_size, + observability.otlp.max_export_batch_size, + observability.otlp.schedule_delay, + observability.otlp.export_timeout + ), + ]; + parts.extend(process_parts); + parts +} diff --git a/crates/crank-config/src/render.rs b/crates/crank-config/src/render.rs new file mode 100644 index 0000000..18e6f56 --- /dev/null +++ b/crates/crank-config/src/render.rs @@ -0,0 +1,110 @@ +use serde::Serialize; + +use crate::{FieldMode, FieldSpec, Sensitivity, deployment_field_registry, field_registry}; + +pub const BEGIN_MARKER: &str = "# BEGIN GENERATED CRANK RUNTIME CONFIG"; +pub const END_MARKER: &str = "# END GENERATED CRANK RUNTIME CONFIG"; +pub const DOC_BEGIN_MARKER: &str = ""; +pub const DOC_END_MARKER: &str = ""; + +#[derive(Serialize)] +struct RuntimeContract<'a> { + schema_version: u32, + generated_by: &'static str, + fields: &'a [FieldSpec], + deployment_only_fields: &'static [&'static str], +} + +pub fn schema_json() -> String { + let contract = RuntimeContract { + schema_version: 1, + generated_by: "crank-config", + fields: field_registry(), + deployment_only_fields: deployment_field_registry(), + }; + let mut rendered = serde_json::to_string_pretty(&contract).expect("static contract serializes"); + rendered.push('\n'); + rendered +} + +pub fn env_section(production: bool) -> String { + let mut output = String::new(); + output.push_str(BEGIN_MARKER); + output.push('\n'); + for field in field_registry() + .iter() + .filter(|field| field.mode == FieldMode::Effective) + { + let value = example_value(field, production); + output.push_str(field.env_name); + output.push('='); + output.push_str(&value); + output.push('\n'); + } + output.push_str(END_MARKER); + output.push('\n'); + output +} + +pub fn reference_section() -> String { + let mut output = String::new(); + output.push_str(DOC_BEGIN_MARKER); + output.push_str("\n\n| Environment | Semantic path | Process | Type/unit | Default | Bounds | Sensitivity | Mode |\n"); + output.push_str("|---|---|---|---|---|---|---|---|\n"); + for field in field_registry() { + let unit = field.unit.unwrap_or("-"); + let default = match (field.sensitivity, field.default, field.required) { + (Sensitivity::Secret, Some(_), _) => "configured", + (Sensitivity::Secret, None, true) => "required/blank", + (Sensitivity::Secret, None, false) => "blank", + (_, Some(default), _) => default, + (_, None, true) => "required/blank", + (_, None, false) => "blank", + }; + let bounds = match (field.minimum, field.maximum) { + (Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"), + _ => "-".to_owned(), + }; + output.push_str(&format!( + "| `{}` | `{}` | `{:?}` | `{}/{}` | `{}` | `{}` | `{:?}` | `{:?}` |\n", + field.env_name, + field.semantic_path, + field.process, + field.value_type, + unit, + default, + bounds, + field.sensitivity, + field.mode, + )); + } + output.push('\n'); + output.push_str(DOC_END_MARKER); + output.push('\n'); + output +} + +pub fn replace_marked(content: &str, begin: &str, end: &str, replacement: &str) -> Option { + let start = content.find(begin)?; + let tail = &content[start..]; + let end_offset = tail.find(end)? + end.len(); + let suffix_start = start + end_offset; + let mut rendered = String::with_capacity(content.len() + replacement.len()); + rendered.push_str(&content[..start]); + rendered.push_str(replacement.trim_end()); + rendered.push_str(&content[suffix_start..]); + Some(rendered) +} + +fn example_value(field: &FieldSpec, production: bool) -> String { + if field.sensitivity == Sensitivity::Secret { + return String::new(); + } + match (field.env_name, production) { + ("CRANK_ENVIRONMENT", true) => "production".to_owned(), + ("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(), + ("POSTGRES_HOST", true) => "postgres".to_owned(), + ("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(), + _ => field.default.unwrap_or("").to_owned(), + } +} diff --git a/crates/crank-config/src/schema.rs b/crates/crank-config/src/schema.rs new file mode 100644 index 0000000..845bb3c --- /dev/null +++ b/crates/crank-config/src/schema.rs @@ -0,0 +1,797 @@ +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessScope { + Shared, + AdminApi, + McpServer, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Sensitivity { + Public, + Internal, + Secret, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldMode { + Effective, + DeprecatedNoEffect, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct FieldSpec { + pub semantic_path: &'static str, + pub env_name: &'static str, + pub process: ProcessScope, + pub value_type: &'static str, + pub unit: Option<&'static str>, + pub default: Option<&'static str>, + pub required: bool, + pub minimum: Option, + pub maximum: Option, + pub sensitivity: Sensitivity, + pub mode: FieldMode, + pub compatibility: Option<&'static str>, + pub rules: &'static [&'static str], +} + +impl FieldSpec { + pub fn default_for(self, process: ProcessScope) -> Option<&'static str> { + match (self.env_name, process) { + ("CRANK_BASE_URL", ProcessScope::AdminApi) => Some("http://localhost:3000"), + ("CRANK_LOG_LEVEL", ProcessScope::AdminApi) => Some("admin_api=info,tower_http=info"), + ("CRANK_LOG_LEVEL", ProcessScope::McpServer) => Some("mcp_server=info,tower_http=info"), + _ => self.default, + } + } +} + +pub(crate) fn semantic_path_for(env_name: &str) -> &str { + field_registry() + .iter() + .find(|field| field.env_name == env_name) + .map_or(env_name, |field| field.semantic_path) +} + +macro_rules! f { + ($path:literal,$name:literal,$proc:ident,$type:literal,$unit:expr,$default:expr,$min:expr,$max:expr,$sensitivity:ident) => { + FieldSpec { + semantic_path: $path, + env_name: $name, + process: ProcessScope::$proc, + value_type: $type, + unit: $unit, + default: $default, + required: false, + minimum: $min, + maximum: $max, + sensitivity: Sensitivity::$sensitivity, + mode: FieldMode::Effective, + compatibility: None, + rules: &[], + } + }; +} + +static FIELDS: [FieldSpec; 57] = [ + FieldSpec { + compatibility: Some("legacy URL form"), + rules: &[ + "takes precedence over generated default-valued POSTGRES_HOST/PORT/DB/USER/PASSWORD", + "conflicts with any non-default decomposed database value", + ], + ..f!( + "database.url", + "CRANK_DATABASE_URL", + Shared, + "url", + None, + None, + None, + None, + Secret + ) + }, + f!( + "database.host", + "POSTGRES_HOST", + Shared, + "string", + None, + Some("postgres"), + None, + None, + Internal + ), + f!( + "database.port", + "POSTGRES_PORT", + Shared, + "u16", + Some("port"), + Some("5432"), + Some(1), + Some(65535), + Public + ), + f!( + "database.name", + "POSTGRES_DB", + Shared, + "string", + None, + Some("crank"), + None, + None, + Internal + ), + f!( + "database.user", + "POSTGRES_USER", + Shared, + "string", + None, + Some("crank"), + None, + None, + Internal + ), + f!( + "database.password", + "POSTGRES_PASSWORD", + Shared, + "secret", + None, + Some("configured"), + None, + None, + Secret + ), + FieldSpec { + rules: &["must be >= min_connections"], + ..f!( + "database.pool.max_connections", + "POSTGRES_MAX_CONNECTIONS", + Shared, + "u32", + Some("connections"), + Some("20"), + Some(1), + Some(1024), + Public + ) + }, + FieldSpec { + rules: &["must be <= max_connections"], + ..f!( + "database.pool.min_connections", + "POSTGRES_MIN_CONNECTIONS", + Shared, + "u32", + Some("connections"), + Some("2"), + Some(0), + Some(1024), + Public + ) + }, + f!( + "database.pool.acquire_timeout_ms", + "POSTGRES_ACQUIRE_TIMEOUT_MS", + Shared, + "u64", + Some("milliseconds"), + Some("5000"), + Some(1), + Some(300000), + Public + ), + f!( + "database.pool.idle_timeout_ms", + "POSTGRES_IDLE_TIMEOUT_MS", + Shared, + "u64", + Some("milliseconds"), + Some("600000"), + Some(1000), + Some(86400000), + Public + ), + f!( + "database.pool.max_lifetime_ms", + "POSTGRES_MAX_LIFETIME_MS", + Shared, + "u64", + Some("milliseconds"), + Some("1800000"), + Some(1000), + Some(86400000), + Public + ), + FieldSpec { + required: true, + ..f!( + "runtime.master_key", + "CRANK_MASTER_KEY", + Shared, + "secret", + None, + None, + None, + None, + Secret + ) + }, + f!( + "runtime.base_url", + "CRANK_BASE_URL", + Shared, + "url", + None, + None, + None, + None, + Internal + ), + f!( + "runtime.max_concurrent_unary", + "CRANK_RUNTIME_MAX_CONCURRENT_UNARY", + Shared, + "u32", + Some("requests"), + Some("64"), + Some(1), + Some(65535), + Public + ), + FieldSpec { + compatibility: Some("redis value is deprecated in favor of valkey"), + rules: &["external backend requires cache.url"], + ..f!( + "cache.backend", + "CRANK_CACHE_BACKEND", + Shared, + "enum", + None, + Some("memory"), + None, + None, + Public + ) + }, + FieldSpec { + rules: &["forbidden with memory backend"], + ..f!( + "cache.url", + "CRANK_CACHE_URL", + Shared, + "url", + None, + None, + None, + None, + Secret + ) + }, + FieldSpec { + mode: FieldMode::DeprecatedNoEffect, + ..f!( + "cache.default_ttl_ms", + "CRANK_CACHE_DEFAULT_TTL_MS", + Shared, + "u64", + Some("milliseconds"), + None, + Some(1), + Some(86400000), + Public + ) + }, + f!( + "outbound.allowed_hosts", + "CRANK_OUTBOUND_ALLOWED_HOSTS", + Shared, + "host_list", + None, + Some(""), + None, + None, + Internal + ), + FieldSpec { + rules: &["deny entries override allow entries"], + ..f!( + "outbound.denied_hosts", + "CRANK_OUTBOUND_DENIED_HOSTS", + Shared, + "host_list", + None, + Some(""), + None, + None, + Internal + ) + }, + f!( + "outbound.max_response_bytes", + "CRANK_OUTBOUND_MAX_RESPONSE_BYTES", + Shared, + "u64", + Some("bytes"), + Some("4194304"), + Some(1), + Some(67108864), + Public + ), + f!( + "observability.environment", + "CRANK_ENVIRONMENT", + Shared, + "label", + None, + Some("development"), + None, + None, + Public + ), + f!( + "observability.log_filter", + "CRANK_LOG_LEVEL", + Shared, + "string", + None, + None, + None, + None, + Public + ), + f!( + "observability.sentry_dsn", + "CRANK_SENTRY_DSN", + Shared, + "url", + None, + None, + None, + None, + Secret + ), + FieldSpec { + compatibility: Some("yes/no/on/off spellings are deprecated"), + ..f!( + "observability.metrics.enabled", + "CRANK_METRICS_ENABLED", + Shared, + "bool", + None, + Some("true"), + None, + None, + Public + ) + }, + FieldSpec { + rules: &["required when an enabled metrics bind is non-loopback"], + ..f!( + "observability.metrics.bearer_token", + "CRANK_METRICS_BEARER_TOKEN", + Shared, + "secret", + None, + None, + None, + None, + Secret + ) + }, + f!( + "observability.otlp.endpoint", + "OTEL_EXPORTER_OTLP_ENDPOINT", + Shared, + "url", + None, + None, + None, + None, + Internal + ), + FieldSpec { + rules: &["overrides generic OTLP endpoint"], + ..f!( + "observability.otlp.traces_endpoint", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + Shared, + "url", + None, + None, + None, + None, + Internal + ) + }, + f!( + "observability.otlp.protocol", + "OTEL_EXPORTER_OTLP_PROTOCOL", + Shared, + "enum", + None, + Some("http/protobuf"), + None, + None, + Public + ), + f!( + "observability.otlp.traces_protocol", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + Shared, + "enum", + None, + None, + None, + None, + Public + ), + f!( + "observability.otlp.timeout", + "OTEL_EXPORTER_OTLP_TIMEOUT", + Shared, + "duration", + Some("milliseconds"), + Some("10000"), + Some(1), + Some(300000), + Public + ), + f!( + "observability.otlp.traces_timeout", + "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + Shared, + "duration", + Some("milliseconds"), + None, + Some(1), + Some(300000), + Public + ), + f!( + "observability.otlp.headers", + "OTEL_EXPORTER_OTLP_HEADERS", + Shared, + "headers", + None, + None, + None, + None, + Secret + ), + f!( + "observability.otlp.traces_headers", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + Shared, + "headers", + None, + None, + None, + None, + Secret + ), + f!( + "observability.otlp.max_queue_size", + "OTEL_BSP_MAX_QUEUE_SIZE", + Shared, + "u32", + Some("spans"), + Some("2048"), + Some(1), + Some(65536), + Public + ), + FieldSpec { + rules: &["must be <= max_queue_size"], + ..f!( + "observability.otlp.max_export_batch_size", + "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", + Shared, + "u32", + Some("spans"), + Some("512"), + Some(1), + Some(65536), + Public + ) + }, + f!( + "observability.otlp.schedule_delay", + "OTEL_BSP_SCHEDULE_DELAY", + Shared, + "duration", + Some("milliseconds"), + Some("5000"), + Some(1), + Some(300000), + Public + ), + f!( + "observability.otlp.export_timeout", + "OTEL_BSP_EXPORT_TIMEOUT", + Shared, + "duration", + Some("milliseconds"), + Some("30000"), + Some(1), + Some(300000), + Public + ), + f!( + "admin.bind", + "CRANK_ADMIN_BIND", + AdminApi, + "socket", + None, + Some("0.0.0.0:3001"), + None, + None, + Internal + ), + f!( + "admin.metrics_bind", + "CRANK_ADMIN_METRICS_BIND", + AdminApi, + "socket", + None, + Some("127.0.0.1:9464"), + None, + None, + Internal + ), + f!( + "admin.storage_root", + "CRANK_STORAGE_ROOT", + AdminApi, + "absolute_path", + None, + Some("/var/lib/crank/storage"), + None, + None, + Internal + ), + f!( + "admin.rate_limit.rps", + "CRANK_ADMIN_RATE_LIMIT_RPS", + AdminApi, + "u32", + Some("requests_per_second"), + Some("30"), + Some(1), + Some(100000), + Public + ), + FieldSpec { + rules: &["must be >= admin rate RPS"], + ..f!( + "admin.rate_limit.burst", + "CRANK_ADMIN_RATE_LIMIT_BURST", + AdminApi, + "u32", + Some("requests"), + Some("60"), + Some(1), + Some(1000000), + Public + ) + }, + f!( + "admin.invocation_log_retention_days", + "CRANK_INVOCATION_LOG_RETENTION_DAYS", + AdminApi, + "u32", + Some("days"), + Some("30"), + Some(1), + Some(36500), + Public + ), + FieldSpec { + required: true, + ..f!( + "admin.session.secret", + "CRANK_SESSION_SECRET", + AdminApi, + "secret", + None, + None, + None, + None, + Secret + ) + }, + FieldSpec { + required: true, + ..f!( + "admin.password_pepper", + "CRANK_PASSWORD_PEPPER", + AdminApi, + "secret", + None, + None, + None, + None, + Secret + ) + }, + f!( + "admin.session.ttl_hours", + "CRANK_SESSION_TTL_HOURS", + AdminApi, + "u32", + Some("hours"), + Some("24"), + Some(1), + Some(8760), + Public + ), + FieldSpec { + compatibility: Some("yes/no/on/off spellings are deprecated"), + ..f!( + "admin.trust_forwarded_headers", + "CRANK_TRUST_FORWARDED_HEADERS", + AdminApi, + "bool", + None, + Some("false"), + None, + None, + Public + ) + }, + FieldSpec { + required: true, + ..f!( + "admin.bootstrap.email", + "CRANK_BOOTSTRAP_ADMIN_EMAIL", + AdminApi, + "string", + None, + None, + None, + None, + Internal + ) + }, + FieldSpec { + required: true, + ..f!( + "admin.bootstrap.password", + "CRANK_BOOTSTRAP_ADMIN_PASSWORD", + AdminApi, + "secret", + None, + None, + None, + None, + Secret + ) + }, + f!( + "admin.bootstrap.display_name", + "CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", + AdminApi, + "string", + None, + Some("Crank Owner"), + None, + None, + Internal + ), + FieldSpec { + compatibility: Some("yes/no/on/off spellings are deprecated"), + ..f!( + "admin.demo_seed", + "CRANK_DEMO_SEED", + AdminApi, + "bool", + None, + Some("false"), + None, + None, + Public + ) + }, + f!( + "mcp.bind", + "CRANK_MCP_BIND", + McpServer, + "socket", + None, + Some("0.0.0.0:3002"), + None, + None, + Internal + ), + f!( + "mcp.metrics_bind", + "CRANK_MCP_METRICS_BIND", + McpServer, + "socket", + None, + Some("127.0.0.1:9465"), + None, + None, + Internal + ), + f!( + "mcp.refresh_ms", + "CRANK_MCP_REFRESH_MS", + McpServer, + "u64", + Some("milliseconds"), + Some("5000"), + Some(100), + Some(3600000), + Public + ), + f!( + "mcp.rate_limit.rps", + "CRANK_MCP_RATE_LIMIT_RPS", + McpServer, + "u32", + Some("requests_per_second"), + Some("60"), + Some(1), + Some(100000), + Public + ), + FieldSpec { + rules: &["must be >= MCP rate RPS"], + ..f!( + "mcp.rate_limit.burst", + "CRANK_MCP_RATE_LIMIT_BURST", + McpServer, + "u32", + Some("requests"), + Some("120"), + Some(1), + Some(1000000), + Public + ) + }, + f!( + "runtime.max_concurrent_sessions", + "CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS", + McpServer, + "u32", + Some("sessions"), + Some("16"), + Some(1), + Some(65535), + Public + ), +]; + +pub fn field_registry() -> &'static [FieldSpec] { + &FIELDS +} + +static DEPLOYMENT_FIELDS: [&str; 12] = [ + "COMPOSE_PROJECT_NAME", + "POSTGRES_PUBLISH_BIND", + "POSTGRES_PUBLISH_PORT", + "CRANK_ADMIN_API_IMAGE", + "CRANK_MCP_SERVER_IMAGE", + "CRANK_UI_IMAGE", + "CRANK_PUBLISH_BIND", + "CRANK_ADMIN_PUBLISH_PORT", + "CRANK_MCP_PUBLISH_PORT", + "CRANK_UI_PUBLISH_PORT", + "VALKEY_PUBLISH_BIND", + "VALKEY_PUBLISH_PORT", +]; + +pub fn deployment_field_registry() -> &'static [&'static str] { + &DEPLOYMENT_FIELDS +} diff --git a/crates/crank-config/src/source.rs b/crates/crank-config/src/source.rs new file mode 100644 index 0000000..6fd8742 --- /dev/null +++ b/crates/crank-config/src/source.rs @@ -0,0 +1,85 @@ +use std::{collections::BTreeMap, ffi::OsString}; + +use crate::{ConfigError, Diagnostic, DiagnosticCode, deployment_field_registry, field_registry}; + +#[derive(Clone, Debug, Default)] +pub struct ConfigSource { + values: BTreeMap, +} + +impl ConfigSource { + pub fn from_utf8(values: BTreeMap) -> Self { + Self { values } + } + + pub fn from_os() -> Result { + Self::from_os_iter(std::env::vars_os()) + } + + pub fn from_os_for_migrator() -> Result { + Self::from_os_iter_filtered(std::env::vars_os(), |name| { + name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_") + }) + } + + pub fn from_os_iter(values: I) -> Result + where + I: IntoIterator, + { + Self::from_os_iter_filtered(values, |_| true) + } + + fn from_os_iter_filtered(values: I, include: F) -> Result + where + I: IntoIterator, + F: Fn(&str) -> bool, + { + let mut parsed = BTreeMap::new(); + let mut diagnostics = Vec::new(); + for (name, value) in values { + let Ok(name) = name.into_string() else { + // Owned names are ASCII. A non-UTF-8 name therefore cannot belong + // to Crank and must not make startup depend on unrelated OS state. + continue; + }; + if !include(&name) { + continue; + } + let owned = name.starts_with("CRANK_") + || name.starts_with("POSTGRES_") + || name.starts_with("OTEL_"); + let known = field_registry().iter().any(|field| field.env_name == name) + || deployment_field_registry().contains(&name.as_str()); + if !owned && !known { + continue; + } + let value = match value.into_string() { + Ok(value) => value, + Err(_) => { + let field = field_registry() + .iter() + .find(|field| field.env_name == name) + .map_or("environment.unknown", |field| field.semantic_path); + diagnostics.push(Diagnostic::new(DiagnosticCode::InvalidEncoding, field)); + continue; + } + }; + parsed.insert(name, value); + } + if diagnostics.is_empty() { + Ok(Self { values: parsed }) + } else { + Err(ConfigError::from_diagnostics(diagnostics)) + } + } + + pub(crate) fn values(&self) -> &BTreeMap { + &self.values + } + + pub(crate) fn retain_for_migrator(mut self) -> Self { + self.values + .retain(|name, _| name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_")); + self + } +} diff --git a/crates/crank-config/src/validation.rs b/crates/crank-config/src/validation.rs new file mode 100644 index 0000000..03ead12 --- /dev/null +++ b/crates/crank-config/src/validation.rs @@ -0,0 +1,42 @@ +pub(crate) fn valid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + } else { + index += 1; + } + } + true +} + +pub(crate) fn valid_database_host(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && !value.chars().any(char::is_whitespace) + && (value.parse::().is_ok() + || value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && !label.starts_with('-') + && !label.ends_with('-') + })) +} + +pub(crate) fn valid_database_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} diff --git a/crates/crank-config/src/value.rs b/crates/crank-config/src/value.rs new file mode 100644 index 0000000..3c8ef27 --- /dev/null +++ b/crates/crank-config/src/value.rs @@ -0,0 +1,42 @@ +use std::fmt; + +#[derive(Clone, Eq, PartialEq)] +pub struct SecretString(String); + +impl SecretString { + pub(crate) fn new(value: String) -> Self { + Self(value) + } + + /// Deliberate composition boundary. Never use in diagnostics or fingerprints. + pub fn expose_secret(&self) -> &str { + &self.0 + } + + pub fn is_configured(&self) -> bool { + !self.0.is_empty() + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("SecretString") + .field(&if self.is_configured() { + "configured" + } else { + "unconfigured" + }) + .finish() + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(if self.is_configured() { + "configured" + } else { + "unconfigured" + }) + } +} diff --git a/crates/crank-config/tests/contract.rs b/crates/crank-config/tests/contract.rs new file mode 100644 index 0000000..e3144c5 --- /dev/null +++ b/crates/crank-config/tests/contract.rs @@ -0,0 +1,558 @@ +use std::collections::BTreeMap; + +use crank_config::{ + ConfigSource, DiagnosticCode, FieldMode, ProcessKind, ProcessScope, field_registry, + parse_migrator, parse_process, +}; + +fn required_admin() -> BTreeMap { + [ + ("CRANK_MASTER_KEY", "master"), + ("CRANK_SESSION_SECRET", "session"), + ("CRANK_PASSWORD_PEPPER", "pepper"), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +fn required_mcp() -> BTreeMap { + [("CRANK_MASTER_KEY", "master")] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +#[test] +fn migrator_projection_requires_only_database_configuration() { + let config = parse_migrator(ConfigSource::from_utf8(BTreeMap::new())) + .expect("database defaults are sufficient for the controlled migration job"); + assert_eq!(config.database.host, "postgres"); + assert_eq!(config.database.port, 5432); + assert_eq!(config.fingerprint().len(), 64); + let debug = format!("{config:?}"); + assert!( + !debug.contains("crank"), + "database password must remain redacted" + ); +} + +#[test] +fn migrator_ignores_service_configuration_and_rejects_database_typos() { + let mut values = BTreeMap::from([ + ("CRANK_MASTER_KEY".to_owned(), "secret-canary".to_owned()), + ( + "CRANK_SESSION_SECRET".to_owned(), + "secret-canary".to_owned(), + ), + ("CRANK_MCP_REFRESH_MS".to_owned(), "invalid".to_owned()), + ]); + parse_migrator(ConfigSource::from_utf8(values.clone())) + .expect("service fields are outside the database-only projection"); + values.insert("POSTGRES_PORRT".to_owned(), "5432".to_owned()); + let error = parse_migrator(ConfigSource::from_utf8(values)).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == DiagnosticCode::UnknownField) + ); + assert!(!error.to_string().contains("secret-canary")); +} + +fn source_for( + field: &crank_config::FieldSpec, + value: String, +) -> (ProcessKind, BTreeMap) { + let kind = match field.process { + ProcessScope::McpServer => ProcessKind::McpServer, + ProcessScope::Shared | ProcessScope::AdminApi => ProcessKind::AdminApi, + }; + let mut vars = match kind { + ProcessKind::AdminApi => required_admin(), + ProcessKind::McpServer => required_mcp(), + }; + vars.insert(field.env_name.to_owned(), value); + match field.env_name { + "POSTGRES_MAX_CONNECTIONS" => { + vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "0".into()); + } + "POSTGRES_MIN_CONNECTIONS" => { + vars.insert("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()); + } + "CRANK_ADMIN_RATE_LIMIT_RPS" => { + vars.insert("CRANK_ADMIN_RATE_LIMIT_BURST".into(), "1000000".into()); + } + "CRANK_ADMIN_RATE_LIMIT_BURST" => { + vars.insert("CRANK_ADMIN_RATE_LIMIT_RPS".into(), "1".into()); + } + "CRANK_MCP_RATE_LIMIT_RPS" => { + vars.insert("CRANK_MCP_RATE_LIMIT_BURST".into(), "1000000".into()); + } + "CRANK_MCP_RATE_LIMIT_BURST" => { + vars.insert("CRANK_MCP_RATE_LIMIT_RPS".into(), "1".into()); + } + "OTEL_BSP_MAX_QUEUE_SIZE" => { + vars.insert("OTEL_BSP_MAX_EXPORT_BATCH_SIZE".into(), "1".into()); + } + "OTEL_BSP_MAX_EXPORT_BATCH_SIZE" => { + vars.insert("OTEL_BSP_MAX_QUEUE_SIZE".into(), "65536".into()); + } + _ => {} + } + (kind, vars) +} + +#[test] +fn registry_covers_exactly_the_57_observed_runtime_names() { + let registry = field_registry(); + assert_eq!(registry.len(), 57); + let unique = registry + .iter() + .map(|field| field.env_name) + .collect::>(); + assert_eq!(unique.len(), registry.len()); + assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW")); + assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS")); + for field in registry { + assert!(!field.semantic_path.is_empty()); + assert!(!field.env_name.is_empty()); + assert!(!field.value_type.is_empty()); + if let (Some(minimum), Some(maximum)) = (field.minimum, field.maximum) { + assert!(minimum <= maximum, "{}", field.env_name); + } + } + assert_eq!( + registry + .iter() + .find(|field| field.env_name == "CRANK_CACHE_DEFAULT_TTL_MS") + .unwrap() + .mode, + FieldMode::DeprecatedNoEffect + ); +} + +#[test] +fn defaults_are_preserved_and_invalid_values_never_fall_back() { + let valid = parse_process( + ProcessKind::AdminApi, + ConfigSource::from_utf8(required_admin()), + ) + .expect("minimal admin config"); + let admin = valid.admin().expect("admin projection"); + assert_eq!(admin.database.port, 5432); + assert_eq!(admin.session_ttl_hours, 24); + assert_eq!(admin.rate_limit.requests_per_second, 30); + + for (name, value) in [ + ("POSTGRES_PORT", "bad"), + ("CRANK_SESSION_TTL_HOURS", "bad"), + ("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"), + ("CRANK_TRUST_FORWARDED_HEADERS", "tru"), + ] { + let mut vars = required_admin(); + vars.insert(name.to_owned(), value.to_owned()); + let error = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + let path = field_registry() + .iter() + .find(|field| field.env_name == name) + .unwrap() + .semantic_path; + assert!(error.diagnostics().iter().any(|item| item.field == path)); + } +} + +#[test] +fn database_forms_conflict_and_owned_typos_fail_closed() { + let mut vars = required_admin(); + vars.insert( + "CRANK_DATABASE_URL".into(), + "postgres://user:secret@db/crank".into(), + ); + vars.insert("POSTGRES_HOST".into(), "db".into()); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.code == DiagnosticCode::Conflict) + ); + + let mut vars = required_admin(); + vars.insert("CRANK_SESION_SECRET".into(), "canary".into()); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.code == DiagnosticCode::UnknownField) + ); + assert!(!error.to_string().contains("canary")); + + for ghost in [ + "CRANK_RUNTIME_MAX_CONCURRENT_WINDOW", + "CRANK_RUNTIME_MAX_CONCURRENT_JOBS", + ] { + let mut vars = required_admin(); + vars.insert(ghost.into(), "4".into()); + let error = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!(error.diagnostics().iter().any(|item| { + item.code == DiagnosticCode::UnknownField && item.field == "environment.unknown" + })); + } +} + +#[test] +fn exact_deployment_only_names_are_known_but_never_runtime_fields() { + let mut vars = required_admin(); + for name in crank_config::deployment_field_registry() { + vars.insert((*name).to_owned(), "deployment-value".to_owned()); + } + let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap(); + assert!(config.admin().is_some()); + assert!( + field_registry() + .iter() + .all(|field| { !crank_config::deployment_field_registry().contains(&field.env_name) }) + ); +} + +#[test] +fn parsed_but_unused_cache_ttl_is_an_explicit_non_pass_contract() { + let mut vars = required_admin(); + vars.insert("CRANK_CACHE_DEFAULT_TTL_MS".into(), "5000".into()); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!(error.diagnostics().iter().any(|item| { + item.code == DiagnosticCode::DeprecatedNoEffect && item.field == "cache.default_ttl_ms" + })); +} + +#[cfg(unix)] +#[test] +fn os_source_rejects_non_utf8_without_echoing_bytes() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let error = ConfigSource::from_os_iter([( + OsString::from("CRANK_MASTER_KEY"), + OsString::from_vec(vec![0xff, b'S', b'E', b'C', b'R', b'E', b'T']), + )]) + .unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.code == DiagnosticCode::InvalidEncoding) + ); + assert!(!error.to_string().contains("SECRET")); +} + +#[cfg(unix)] +#[test] +fn os_source_ignores_unrelated_invalid_or_unbounded_values() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + ConfigSource::from_os_iter([ + ( + OsString::from_vec(vec![0xff]), + OsString::from_vec(vec![0xff]), + ), + ( + OsString::from("JAVA_TOOL_OPTIONS"), + OsString::from("x".repeat(20_000)), + ), + (OsString::from("LANG"), OsString::from_vec(vec![0xff, b'x'])), + ]) + .expect("unrelated OS state is outside the runtime contract"); +} + +#[test] +fn process_specific_fields_and_zero_ports_fail_closed() { + let mut mcp = required_mcp(); + mcp.insert("CRANK_SESSION_SECRET".into(), "wrong-process".into()); + let error = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp)).unwrap_err(); + assert!(error.diagnostics().iter().any(|item| { + item.code == DiagnosticCode::UnknownField && item.field == "admin.session.secret" + })); + + for (kind, name, required) in [ + (ProcessKind::AdminApi, "CRANK_ADMIN_BIND", required_admin()), + ( + ProcessKind::AdminApi, + "CRANK_ADMIN_METRICS_BIND", + required_admin(), + ), + (ProcessKind::McpServer, "CRANK_MCP_BIND", required_mcp()), + ( + ProcessKind::McpServer, + "CRANK_MCP_METRICS_BIND", + required_mcp(), + ), + ] { + let mut vars = required; + vars.insert(name.into(), "127.0.0.1:0".into()); + let error = parse_process(kind, ConfigSource::from_utf8(vars)).unwrap_err(); + let path = field_registry() + .iter() + .find(|field| field.env_name == name) + .unwrap() + .semantic_path; + assert!( + error + .diagnostics() + .iter() + .any(|item| { item.code == DiagnosticCode::OutOfRange && item.field == path }) + ); + } +} + +#[test] +fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() { + for name in [ + "CRANK_MASTER_KEY", + "CRANK_SESSION_SECRET", + "CRANK_PASSWORD_PEPPER", + "CRANK_BOOTSTRAP_ADMIN_PASSWORD", + ] { + let mut vars = required_admin(); + vars.insert(name.into(), " ".into()); + assert!( + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).is_err(), + "{name}" + ); + } + for (name, value) in [ + ("CRANK_OUTBOUND_ALLOWED_HOSTS", "example.test:443"), + ("CRANK_DATABASE_URL", "postgres://db/crank?sslmode=bogus"), + ("CRANK_ENVIRONMENT", "bad environment"), + ("CRANK_SENTRY_DSN", "not-a-dsn"), + ("OTEL_EXPORTER_OTLP_HEADERS", "bad name=value"), + ( + "CRANK_BASE_URL", + "https://user:secret@example.test/path?token=x", + ), + ] { + let mut vars = required_admin(); + vars.insert(name.into(), value.into()); + let error = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.code == DiagnosticCode::InvalidType), + "{name}: {error}" + ); + } +} + +#[test] +fn cross_field_and_typed_boundaries_fail_closed() { + let cases = [ + ("POSTGRES_MAX_CONNECTIONS", "1025"), + ("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"), + ("CRANK_ADMIN_RATE_LIMIT_BURST", "0"), + ("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"), + ("OTEL_BSP_SCHEDULE_DELAY", "bad"), + ("OTEL_BSP_EXPORT_TIMEOUT", "300001"), + ("CRANK_BASE_URL", "file:///tmp/config"), + ]; + for (name, value) in cases { + let mut vars = required_admin(); + vars.insert(name.to_owned(), value.to_owned()); + let error = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name); + assert!( + error.diagnostics().iter().any(|item| item.field + == field_registry() + .iter() + .find(|field| field.env_name == name) + .unwrap() + .semantic_path), + "{name}: {error}" + ); + } + + let mut vars = required_admin(); + vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "21".into()); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.code == DiagnosticCode::UnsafeCombination) + ); + + let mut vars = required_admin(); + vars.insert("CRANK_CACHE_BACKEND".into(), "valkey".into()); + vars.insert("CRANK_CACHE_URL".into(), "https://not-a-cache.test".into()); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.field == "cache.url") + ); +} + +#[test] +fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() { + let mut vars = required_admin(); + vars.extend([ + ("POSTGRES_PORT".into(), "65535".into()), + ("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()), + ("POSTGRES_MIN_CONNECTIONS".into(), "0".into()), + ("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()), + ( + "CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(), + "67108864".into(), + ), + ("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()), + ("CRANK_DEMO_SEED".into(), "off".into()), + ]); + let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap(); + let admin = config.admin().unwrap(); + assert_eq!(admin.database.port, 65_535); + assert_eq!(admin.database.pool.max_connections, 1024); + assert_eq!(admin.database.pool.min_connections, 0); + assert_eq!(admin.runtime.max_concurrent_unary, 1); + assert!(admin.trust_forwarded_headers); + assert!(!admin.demo_seed); +} + +#[test] +fn admin_and_mcp_share_one_normalized_foundation() { + let mut vars = required_admin(); + vars.extend([ + ("CRANK_BASE_URL".into(), "https://crank.example.test".into()), + ("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "72".into()), + ( + "CRANK_OUTBOUND_ALLOWED_HOSTS".into(), + "api.example.test".into(), + ), + ("POSTGRES_MAX_CONNECTIONS".into(), "24".into()), + ]); + let admin = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars.clone())).unwrap(); + let mut mcp_vars = vars; + for key in [ + "CRANK_SESSION_SECRET", + "CRANK_PASSWORD_PEPPER", + "CRANK_BOOTSTRAP_ADMIN_EMAIL", + "CRANK_BOOTSTRAP_ADMIN_PASSWORD", + ] { + mcp_vars.remove(key); + } + let mcp = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp_vars)).unwrap(); + let admin = admin.admin().unwrap(); + let mcp = mcp.mcp().unwrap(); + assert_eq!(admin.database.host, mcp.database.host); + assert_eq!( + admin.database.pool.max_connections, + mcp.database.pool.max_connections + ); + assert_eq!(admin.runtime.base_url, mcp.runtime.base_url); + assert_eq!( + admin.runtime.max_concurrent_unary, + mcp.runtime.max_concurrent_unary + ); + assert_eq!( + admin.runtime.outbound.allowed_hosts, + mcp.runtime.outbound.allowed_hosts + ); +} + +#[test] +fn every_bounded_numeric_field_accepts_edges_and_rejects_outside_values() { + for field in field_registry().iter().filter(|field| { + field.mode == FieldMode::Effective && field.minimum.is_some() && field.maximum.is_some() + }) { + let minimum = field.minimum.unwrap(); + let maximum = field.maximum.unwrap(); + for accepted in [minimum, maximum] { + let (kind, vars) = source_for(field, accepted.to_string()); + parse_process(kind, ConfigSource::from_utf8(vars)) + .unwrap_or_else(|error| panic!("{}={accepted}: {error}", field.env_name)); + } + for rejected in [ + if minimum == 0 { + "-1".to_owned() + } else { + (minimum - 1).to_string() + }, + (maximum + 1).to_string(), + ] { + let (kind, vars) = source_for(field, rejected); + let error = + parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.field == field.semantic_path), + "{}: {error}", + field.env_name + ); + } + + for malformed in ["-1", "184467440737095516160", "1.5", " 1", "1\n"] { + let (kind, vars) = source_for(field, malformed.to_owned()); + let error = + parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name); + assert!( + error + .diagnostics() + .iter() + .any(|item| item.field == field.semantic_path), + "{}={malformed:?}: {error}", + field.env_name + ); + } + } +} + +#[test] +fn compatibility_values_and_otel_precedence_remain_explicit() { + let mut vars = required_admin(); + vars.extend([ + ("CRANK_CACHE_BACKEND".into(), "redis".into()), + ( + "CRANK_CACHE_URL".into(), + "redis://cache.example.test:6379".into(), + ), + ( + "OTEL_EXPORTER_OTLP_ENDPOINT".into(), + "https://generic.example.test".into(), + ), + ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT".into(), + "https://traces.example.test".into(), + ), + ("OTEL_EXPORTER_OTLP_TIMEOUT".into(), "10000".into()), + ("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT".into(), "5000".into()), + ]); + let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap(); + let admin = config.admin().unwrap(); + assert_eq!( + admin.runtime.cache.backend, + crank_config::CacheBackend::Redis + ); + assert_eq!( + admin.observability.otlp.endpoint.as_deref(), + Some("https://generic.example.test") + ); + assert_eq!( + admin.observability.otlp.traces_endpoint.as_deref(), + Some("https://traces.example.test") + ); + assert_eq!(admin.observability.otlp.timeout.as_deref(), Some("10000")); + assert_eq!( + admin.observability.otlp.traces_timeout.as_deref(), + Some("5000") + ); + assert_eq!(config.deprecations().len(), 1); + assert_eq!(config.deprecations()[0].field, "cache.backend"); +} diff --git a/crates/crank-config/tests/generation.rs b/crates/crank-config/tests/generation.rs new file mode 100644 index 0000000..f1e42d7 --- /dev/null +++ b/crates/crank-config/tests/generation.rs @@ -0,0 +1,48 @@ +use crank_config::{field_registry, render}; + +#[test] +fn generated_contract_is_deterministic_complete_and_redacted() { + assert_eq!(render::schema_json(), render::schema_json()); + let schema: serde_json::Value = serde_json::from_str(&render::schema_json()).unwrap(); + assert_eq!( + schema["fields"].as_array().unwrap().len(), + field_registry().len() + ); + for section in [render::env_section(false), render::env_section(true)] { + assert!(!section.contains("change-me")); + assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW")); + assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS")); + assert!(!section.contains("CRANK_CACHE_DEFAULT_TTL_MS")); + } +} + +#[test] +fn generated_reference_distinguishes_required_and_optional_fields() { + let reference = render::reference_section(); + + assert!(reference.contains( + "| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |" + )); + assert!( + reference + .contains("| `CRANK_DATABASE_URL` | `database.url` | `Shared` | `url/-` | `blank` |") + ); + assert!(reference.contains( + "| `CRANK_LOG_LEVEL` | `observability.log_filter` | `Shared` | `string/-` | `blank` |" + )); +} + +#[test] +fn marker_replacement_is_bounded_to_the_generated_region() { + let input = "before\n# BEGIN GENERATED CRANK RUNTIME CONFIG\nstale\n# END GENERATED CRANK RUNTIME CONFIG\nafter\n"; + let output = render::replace_marked( + input, + render::BEGIN_MARKER, + render::END_MARKER, + &render::env_section(false), + ) + .unwrap(); + assert!(output.starts_with("before\n")); + assert!(output.ends_with("\nafter\n")); + assert!(!output.contains("stale")); +} diff --git a/crates/crank-config/tests/redaction.rs b/crates/crank-config/tests/redaction.rs new file mode 100644 index 0000000..75e93f7 --- /dev/null +++ b/crates/crank-config/tests/redaction.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeMap; + +use crank_config::{ConfigSource, ProcessKind, parse_process}; + +fn config(secret: &str) -> crank_config::EffectiveConfig { + let vars = [ + ("CRANK_MASTER_KEY", secret), + ("CRANK_SESSION_SECRET", secret), + ("CRANK_PASSWORD_PEPPER", secret), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", secret), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap() +} + +#[test] +fn secrets_are_absent_from_debug_display_and_fingerprint() { + let first = config("CANARY_ONE"); + let second = config("CANARY_TWO"); + let rendered = format!("{first:?}"); + assert!(!rendered.contains("CANARY_ONE")); + assert_eq!(first.fingerprint(), second.fingerprint()); + assert_eq!(first.fingerprint().len(), 64); + assert!( + first + .fingerprint() + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); +} + +#[test] +fn effective_semantics_not_input_spelling_drive_fingerprint() { + let mut canonical = [ + ("CRANK_MASTER_KEY", "master"), + ("CRANK_SESSION_SECRET", "session"), + ("CRANK_PASSWORD_PEPPER", "pepper"), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"), + ("CRANK_TRUST_FORWARDED_HEADERS", "true"), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let mut compatibility = canonical.clone(); + compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()); + + let canonical_config = parse_process( + ProcessKind::AdminApi, + ConfigSource::from_utf8(canonical.clone()), + ) + .unwrap(); + let compatibility_config = parse_process( + ProcessKind::AdminApi, + ConfigSource::from_utf8(compatibility), + ) + .unwrap(); + assert_eq!( + canonical_config.fingerprint(), + compatibility_config.fingerprint() + ); + assert_eq!(compatibility_config.deprecations().len(), 1); + + canonical.insert("CRANK_SESSION_TTL_HOURS".into(), "48".into()); + let changed = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(canonical)).unwrap(); + assert_ne!(changed.fingerprint(), compatibility_config.fingerprint()); +} + +#[test] +fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() { + let canary = "CANARY_SECRET_VALUE"; + let vars = [ + ("CRANK_MASTER_KEY", canary), + ("CRANK_SESSION_SECRET", canary), + ("CRANK_PASSWORD_PEPPER", canary), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", canary), + ( + "CRANK_DATABASE_URL", + "postgres://owner:CANARY_SECRET_VALUE@db/crank", + ), + ("POSTGRES_PASSWORD", canary), + ("CRANK_CACHE_BACKEND", "memory"), + ("CRANK_CACHE_URL", "redis://:CANARY_SECRET_VALUE@cache:6379"), + ( + "CRANK_SENTRY_DSN", + "https://CANARY_SECRET_VALUE@sentry.test/1", + ), + ("CRANK_METRICS_BEARER_TOKEN", canary), + ( + "OTEL_EXPORTER_OTLP_HEADERS", + "authorization=CANARY_SECRET_VALUE", + ), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err(); + let display = error.to_string(); + let json = error.to_json(); + assert!(json.len() <= 65_536); + assert!(serde_json::from_str::(&json).is_ok()); + assert!(!display.contains(canary)); + assert!(!json.contains(canary)); + assert!(error.diagnostics().len() <= 100); +} + +#[test] +fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() { + let mut vars = [ + ("CRANK_MASTER_KEY", "master"), + ("CRANK_SESSION_SECRET", "session"), + ("CRANK_PASSWORD_PEPPER", "pepper"), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"), + ("CRANK_STORAGE_ROOT", "/CANARY/private/storage"), + ("POSTGRES_HOST", "CANARY-db.internal"), + ("CRANK_OUTBOUND_ALLOWED_HOSTS", "CANARY-api.internal"), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect::>(); + vars.insert( + "CRANK_BASE_URL".into(), + "https://CANARY.example.test".into(), + ); + let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap(); + let rendered = format!("{:?}", config.admin().unwrap()); + assert!(!rendered.contains("CANARY"), "{rendered}"); +} + +#[test] +fn normalized_database_and_admin_default_urls_drive_fingerprint() { + let base = [ + ("CRANK_MASTER_KEY", "master"), + ("CRANK_SESSION_SECRET", "session"), + ("CRANK_PASSWORD_PEPPER", "pepper"), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect::>(); + let implicit = + parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(base.clone())).unwrap(); + let mut explicit = base.clone(); + explicit.insert("CRANK_BASE_URL".into(), "http://localhost:3000".into()); + let explicit = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(explicit)).unwrap(); + assert_eq!(implicit.fingerprint(), explicit.fingerprint()); + + let mut url = base; + url.insert( + "CRANK_DATABASE_URL".into(), + "postgres://crank:rotated@postgres/crank".into(), + ); + let url = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(url)).unwrap(); + assert_eq!(implicit.fingerprint(), url.fingerprint()); + + let tls = [ + ("CRANK_MASTER_KEY", "master"), + ("CRANK_SESSION_SECRET", "session"), + ("CRANK_PASSWORD_PEPPER", "pepper"), + ("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"), + ("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"), + ( + "CRANK_DATABASE_URL", + "postgres://crank:rotated@postgres/crank?sslmode=require", + ), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect::>(); + let tls = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(tls)).unwrap(); + assert_ne!(implicit.fingerprint(), tls.fingerprint()); +} diff --git a/crates/crank-core/Cargo.toml b/crates/crank-core/Cargo.toml index 4df892f..e09d386 100644 --- a/crates/crank-core/Cargo.toml +++ b/crates/crank-core/Cargo.toml @@ -12,6 +12,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true time.workspace = true +uuid.workspace = true [dev-dependencies] serde_yaml.workspace = true diff --git a/crates/crank-core/src/correlation.rs b/crates/crank-core/src/correlation.rs new file mode 100644 index 0000000..f674c2e --- /dev/null +++ b/crates/crank-core/src/correlation.rs @@ -0,0 +1,275 @@ +use serde::{Deserialize, Deserializer, Serialize, de}; +use uuid::Uuid; + +const TRACEPARENT_VERSION: &str = "00"; +const ZERO_TRACE_ID: &str = "00000000000000000000000000000000"; +const ZERO_PARENT_ID: &str = "0000000000000000"; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct RequestId(String); + +impl RequestId { + pub const MAX_LEN: usize = 128; + + pub fn generate() -> Self { + Self(Uuid::now_v7().to_string()) + } + + pub fn resolve(candidate: Option<&str>) -> Self { + candidate + .filter(|value| Self::is_valid(value)) + .map(|value| Self(value.to_owned())) + .unwrap_or_else(Self::generate) + } + + pub fn parse(value: &str) -> Result { + Self::is_valid(value) + .then(|| Self(value.to_owned())) + .ok_or(CorrelationError::InvalidRequestId) + } + + pub fn is_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= Self::MAX_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b',' && byte != b';') + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for RequestId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for RequestId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct TraceId(String); + +impl TraceId { + pub const LEN: usize = 32; + + pub fn generate() -> Self { + let value = Uuid::now_v7().simple().to_string(); + debug_assert_ne!(value, ZERO_TRACE_ID); + Self(value) + } + + pub fn parse(value: &str) -> Result { + if is_lower_hex(value, Self::LEN) && value != ZERO_TRACE_ID { + Ok(Self(value.to_owned())) + } else { + Err(CorrelationError::InvalidTraceId) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for TraceId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for TraceId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TraceContext { + trace_id: TraceId, + traceparent: String, +} + +impl TraceContext { + pub const TRACEPARENT_LEN: usize = 55; + pub const TRACESTATE_MAX_BYTES: usize = 512; + pub const TRACESTATE_MAX_MEMBERS: usize = 32; + pub const BAGGAGE_MAX_BYTES: usize = 8_192; + pub const BAGGAGE_MAX_MEMBERS: usize = 64; + + pub fn generate() -> Self { + let trace_id = TraceId::generate(); + let mut parent_id = Uuid::now_v7().simple().to_string()[..16].to_owned(); + if parent_id == ZERO_PARENT_ID { + parent_id.replace_range(15..16, "1"); + } + // A context generated outside an SDK span must not claim that a sampler + // selected it. Ingress replaces this seed with the actual local span + // context before application code runs. + let traceparent = format!("{TRACEPARENT_VERSION}-{trace_id}-{parent_id}-00"); + Self { + trace_id, + traceparent, + } + } + + pub fn parse(value: &str) -> Result { + if value.len() != Self::TRACEPARENT_LEN { + return Err(CorrelationError::InvalidTraceparent); + } + let bytes = value.as_bytes(); + if bytes[2] != b'-' || bytes[35] != b'-' || bytes[52] != b'-' { + return Err(CorrelationError::InvalidTraceparent); + } + let version = &value[0..2]; + let trace_id = &value[3..35]; + let parent_id = &value[36..52]; + let flags = &value[53..55]; + if version != TRACEPARENT_VERSION + || !is_lower_hex(parent_id, 16) + || parent_id == ZERO_PARENT_ID + || !matches!(flags, "00" | "01") + { + return Err(CorrelationError::InvalidTraceparent); + } + Ok(Self { + trace_id: TraceId::parse(trace_id).map_err(|_| CorrelationError::InvalidTraceparent)?, + traceparent: value.to_owned(), + }) + } + + pub fn from_span_parts( + trace_id: &str, + span_id: &str, + sampled: bool, + ) -> Result { + let flags = if sampled { "01" } else { "00" }; + Self::parse(&format!( + "{TRACEPARENT_VERSION}-{trace_id}-{span_id}-{flags}" + )) + } + + pub fn continue_local(&self) -> Self { + let mut span_id = Uuid::now_v7().simple().to_string()[..16].to_owned(); + if span_id == ZERO_PARENT_ID { + span_id.replace_range(15..16, "1"); + } + let sampled = self.traceparent.ends_with("-01"); + Self::from_span_parts(self.trace_id.as_str(), &span_id, sampled) + .expect("generated span identity is canonical") + } + + pub fn trace_id(&self) -> &TraceId { + &self.trace_id + } + + pub fn traceparent(&self) -> &str { + &self.traceparent + } + + pub fn tracestate_within_budget(value: &str) -> bool { + header_list_within_budget( + value, + Self::TRACESTATE_MAX_BYTES, + Self::TRACESTATE_MAX_MEMBERS, + ) + } + + pub fn baggage_within_budget(value: &str) -> bool { + header_list_within_budget(value, Self::BAGGAGE_MAX_BYTES, Self::BAGGAGE_MAX_MEMBERS) + } +} + +impl<'de> Deserialize<'de> for TraceContext { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct WireTraceContext { + trace_id: TraceId, + traceparent: String, + } + + let wire = WireTraceContext::deserialize(deserializer)?; + let context = Self::parse(&wire.traceparent).map_err(de::Error::custom)?; + if context.trace_id != wire.trace_id { + return Err(de::Error::custom(CorrelationError::InvalidTraceparent)); + } + Ok(context) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CorrelationContext { + request_id: RequestId, + trace_context: TraceContext, +} + +impl CorrelationContext { + pub fn new(request_id: RequestId, trace_context: TraceContext) -> Self { + Self { + request_id, + trace_context, + } + } + + pub fn generate() -> Self { + Self::new(RequestId::generate(), TraceContext::generate()) + } + + pub fn request_id(&self) -> &RequestId { + &self.request_id + } + + pub fn trace_context(&self) -> &TraceContext { + &self.trace_context + } + + pub fn trace_id(&self) -> &TraceId { + self.trace_context.trace_id() + } +} + +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +pub enum CorrelationError { + #[error("invalid request identity")] + InvalidRequestId, + #[error("invalid trace identity")] + InvalidTraceId, + #[error("invalid trace parent")] + InvalidTraceparent, +} + +fn is_lower_hex(value: &str, expected_len: usize) -> bool { + value.len() == expected_len + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn header_list_within_budget(value: &str, max_bytes: usize, max_members: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value.is_ascii() + && !value.bytes().any(|byte| byte.is_ascii_control()) + && value.split(',').count() <= max_members + && value.split(',').all(|member| !member.trim().is_empty()) +} diff --git a/crates/crank-core/src/ext/protocol.rs b/crates/crank-core/src/ext/protocol.rs index 482a34f..8c57b4a 100644 --- a/crates/crank-core/src/ext/protocol.rs +++ b/crates/crank-core/src/ext/protocol.rs @@ -4,7 +4,10 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{AgentId, InvocationSource, Protocol, Target, WorkspaceId}; +use crate::{ + AgentId, CorrelationContext, InvocationSource, Protocol, RequestId, Target, TraceContext, + WorkspaceId, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -20,8 +23,8 @@ pub struct ResponseCacheScope { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeRequestContext { - pub request_id: String, - pub correlation_id: String, + pub request_id: RequestId, + pub trace_context: TraceContext, pub response_cache_scope: Option, pub metering_context: Option, } @@ -34,10 +37,10 @@ pub struct MeteringContext { } impl RuntimeRequestContext { - pub fn new(request_id: impl Into, correlation_id: impl Into) -> Self { + pub fn new(request_id: RequestId, trace_context: TraceContext) -> Self { Self { - request_id: request_id.into(), - correlation_id: correlation_id.into(), + request_id, + trace_context, response_cache_scope: None, metering_context: None, } @@ -45,13 +48,33 @@ impl RuntimeRequestContext { pub fn from_request_id(request_id: impl Into) -> Self { let request_id = request_id.into(); - Self::new(request_id.clone(), request_id) + Self::new( + RequestId::resolve(Some(&request_id)), + TraceContext::generate(), + ) + } + + pub fn from_correlation(context: &CorrelationContext) -> Self { + Self::new( + context.request_id().clone(), + context.trace_context().clone(), + ) } pub fn outbound_headers(&self) -> BTreeMap { BTreeMap::from([ - ("x-request-id".to_owned(), self.request_id.clone()), - ("x-correlation-id".to_owned(), self.correlation_id.clone()), + ("x-request-id".to_owned(), self.request_id.to_string()), + ( + "x-trace-id".to_owned(), + self.trace_context.trace_id().to_string(), + ), + ( + "traceparent".to_owned(), + self.trace_context.traceparent().to_owned(), + ), + // Compatibility alias only. It is intentionally the product Request ID, + // never the W3C Trace ID. + ("x-correlation-id".to_owned(), self.request_id.to_string()), ]) } diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index d350929..f19fc88 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod agent; pub mod approval; pub mod auth; pub mod cache; +pub mod correlation; pub mod edition; pub mod ext; pub mod ids; @@ -113,6 +114,7 @@ pub use cache::{ ParseCacheBackendError, RateLimitBucketState, RateLimitDecision, RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore, }; +pub use correlation::{CorrelationContext, CorrelationError, RequestId, TraceContext, TraceId}; pub use edition::{ EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition, }; diff --git a/crates/crank-core/src/observability.rs b/crates/crank-core/src/observability.rs index 191e920..cc834e8 100644 --- a/crates/crank-core/src/observability.rs +++ b/crates/crank-core/src/observability.rs @@ -118,6 +118,7 @@ pub struct InvocationLog { pub tool_name: String, pub message: String, pub request_id: Option, + pub trace_id: Option, pub status_code: Option, pub duration_ms: u64, pub error_kind: Option, @@ -169,6 +170,7 @@ mod tests { tool_name: "create_lead".to_owned(), message: "ok".to_owned(), request_id: Some("req_01".to_owned()), + trace_id: Some("0af7651916cd43dd8448eb211c80319c".to_owned()), status_code: Some(200), duration_ms: 123, error_kind: None, diff --git a/crates/crank-core/tests/correlation.rs b/crates/crank-core/tests/correlation.rs new file mode 100644 index 0000000..aacbb58 --- /dev/null +++ b/crates/crank-core/tests/correlation.rs @@ -0,0 +1,131 @@ +use crank_core::{CorrelationContext, RequestId, TraceContext, TraceId}; +use uuid::Version; + +const VALID_TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + +#[test] +fn generated_identities_are_distinct_and_canonical() { + let context = CorrelationContext::generate(); + + assert_eq!( + uuid::Uuid::parse_str(context.request_id().as_str()) + .unwrap() + .get_version(), + Some(Version::SortRand) + ); + assert_eq!(context.trace_id().as_str().len(), 32); + assert!( + context + .trace_id() + .as_str() + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + assert_ne!( + context.request_id().as_str().replace('-', ""), + context.trace_id().as_str() + ); +} + +#[test] +fn request_id_preserves_one_valid_opaque_value_and_replaces_invalid_values() { + assert_eq!( + RequestId::resolve(Some("gateway-request-42")).as_str(), + "gateway-request-42" + ); + for invalid in ["", "bad value", "bad,value", "bad;value"] { + let replacement = RequestId::resolve(Some(invalid)); + assert_ne!(replacement.as_str(), invalid); + assert_eq!( + uuid::Uuid::parse_str(replacement.as_str()) + .unwrap() + .get_version(), + Some(Version::SortRand) + ); + } + assert!(RequestId::is_valid(&"a".repeat(RequestId::MAX_LEN))); + assert!(!RequestId::is_valid(&"a".repeat(RequestId::MAX_LEN + 1))); +} + +#[test] +fn traceparent_parser_is_strict_and_never_accepts_zero_ids() { + let context = TraceContext::parse(VALID_TRACEPARENT).unwrap(); + assert_eq!( + context.trace_id().as_str(), + "0af7651916cd43dd8448eb211c80319c" + ); + assert_eq!(context.traceparent(), VALID_TRACEPARENT); + + for invalid in [ + "00-00000000000000000000000000000000-b7ad6b7169203331-01", + "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01", + "00-0AF7651916CD43DD8448EB211C80319C-b7ad6b7169203331-01", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-0z", + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-02", + "ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "canary-invalid-traceparent", + ] { + assert!(TraceContext::parse(invalid).is_err(), "accepted {invalid}"); + } + assert!(TraceId::parse("00000000000000000000000000000000").is_err()); +} + +#[test] +fn durable_parent_envelope_roundtrips_without_conflating_ids() { + let context = CorrelationContext::new( + RequestId::resolve(Some("request-opaque-1")), + TraceContext::parse(VALID_TRACEPARENT).unwrap(), + ); + let encoded = serde_json::to_vec(&context).unwrap(); + let decoded: CorrelationContext = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, context); + assert_eq!(decoded.request_id().as_str(), "request-opaque-1"); + assert_eq!( + decoded.trace_id().as_str(), + "0af7651916cd43dd8448eb211c80319c" + ); +} + +#[test] +fn durable_parent_envelope_rejects_invalid_or_inconsistent_identities() { + for candidate in [ + serde_json::json!({ + "request_id": "bad request", + "trace_context": { + "trace_id": "0af7651916cd43dd8448eb211c80319c", + "traceparent": VALID_TRACEPARENT, + } + }), + serde_json::json!({ + "request_id": "request-1", + "trace_context": { + "trace_id": "00000000000000000000000000000000", + "traceparent": VALID_TRACEPARENT, + } + }), + serde_json::json!({ + "request_id": "request-1", + "trace_context": { + "trace_id": "1af7651916cd43dd8448eb211c80319c", + "traceparent": VALID_TRACEPARENT, + } + }), + ] { + assert!(serde_json::from_value::(candidate).is_err()); + } +} + +#[test] +fn caller_state_and_baggage_budgets_are_closed_and_bounded() { + assert!(TraceContext::tracestate_within_budget("vendor=value")); + assert!(!TraceContext::tracestate_within_budget(&"x".repeat(513))); + assert!(!TraceContext::tracestate_within_budget( + &std::iter::repeat_n("a=b", 33).collect::>().join(",") + )); + assert!(TraceContext::baggage_within_budget("key=value")); + assert!(!TraceContext::baggage_within_budget(&"x".repeat(8_193))); + assert!(!TraceContext::baggage_within_budget( + &std::iter::repeat_n("a=b", 65).collect::>().join(",") + )); +} diff --git a/crates/crank-observability/Cargo.toml b/crates/crank-observability/Cargo.toml index 96eea7f..aa53339 100644 --- a/crates/crank-observability/Cargo.toml +++ b/crates/crank-observability/Cargo.toml @@ -27,7 +27,6 @@ tracing.workspace = true tracing-opentelemetry.workspace = true tracing-subscriber.workspace = true url.workspace = true -uuid.workspace = true [dev-dependencies] opentelemetry-proto.workspace = true diff --git a/crates/crank-observability/src/config.rs b/crates/crank-observability/src/config.rs index bac9c35..7bd0830 100644 --- a/crates/crank-observability/src/config.rs +++ b/crates/crank-observability/src/config.rs @@ -1,10 +1,8 @@ -use std::env; - use thiserror::Error; +use tracing_subscriber::EnvFilter; use crate::RedactionLimits; -const DEFAULT_ENVIRONMENT: &str = "development"; const MAX_IDENTITY_LABEL_BYTES: usize = 64; #[derive(Clone, Debug, Eq, PartialEq)] @@ -52,6 +50,20 @@ pub struct ObservabilityConfig { } impl ObservabilityConfig { + pub fn try_new( + identity: ServiceIdentity, + filter: impl Into, + redaction_limits: RedactionLimits, + ) -> Result { + let filter = filter.into(); + EnvFilter::try_new(&filter).map_err(|_| ObservabilityConfigError::InvalidFilter)?; + Ok(Self { + identity, + filter, + redaction_limits, + }) + } + pub fn new( identity: ServiceIdentity, filter: impl Into, @@ -64,26 +76,6 @@ impl ObservabilityConfig { } } - pub fn from_env( - service: &'static str, - version: &'static str, - default_filter: &'static str, - ) -> Result { - let environment = env_value_or_default( - "CRANK_ENVIRONMENT", - env::var("CRANK_ENVIRONMENT"), - DEFAULT_ENVIRONMENT, - )?; - let filter = env_value_or_default( - "CRANK_LOG_LEVEL", - env::var("CRANK_LOG_LEVEL"), - default_filter, - )?; - let identity = ServiceIdentity::try_new(service, version, environment)?; - - Ok(Self::new(identity, filter, RedactionLimits::default())) - } - pub(crate) fn into_parts(self) -> (ServiceIdentity, String, RedactionLimits) { (self.identity, self.filter, self.redaction_limits) } @@ -101,22 +93,8 @@ impl ObservabilityConfig { pub enum ObservabilityConfigError { #[error("invalid observability identity field: {field}")] InvalidIdentity { field: &'static str }, - #[error("observability environment variable is not valid UTF-8: {field}")] - InvalidEnvironmentEncoding { field: &'static str }, -} - -fn env_value_or_default( - field: &'static str, - value: Result, - default: &'static str, -) -> Result { - match value { - Ok(value) => Ok(value), - Err(env::VarError::NotPresent) => Ok(default.to_owned()), - Err(env::VarError::NotUnicode(_)) => { - Err(ObservabilityConfigError::InvalidEnvironmentEncoding { field }) - } - } + #[error("invalid observability log filter")] + InvalidFilter, } fn validate_label(field: &'static str, value: &str) -> Result<(), ObservabilityConfigError> { @@ -135,9 +113,7 @@ fn validate_label(field: &'static str, value: &str) -> Result<(), ObservabilityC #[cfg(test)] mod tests { - use std::ffi::OsString; - - use super::{ObservabilityConfigError, ServiceIdentity, env_value_or_default}; + use super::ServiceIdentity; #[test] fn accepts_release_and_environment_labels() { @@ -148,23 +124,4 @@ mod tests { assert_eq!(identity.version(), "0.3.1+build.7"); assert_eq!(identity.environment(), "production"); } - - #[test] - fn rejects_non_utf8_environment_values() { - let error = env_value_or_default( - "CRANK_ENVIRONMENT", - Err(std::env::VarError::NotUnicode(OsString::from( - "invalid-environment", - ))), - "development", - ) - .expect_err("non-UTF-8 values must not be replaced with defaults"); - - assert!(matches!( - error, - ObservabilityConfigError::InvalidEnvironmentEncoding { - field: "CRANK_ENVIRONMENT" - } - )); - } } diff --git a/crates/crank-observability/src/correlation.rs b/crates/crank-observability/src/correlation.rs deleted file mode 100644 index beaa51d..0000000 --- a/crates/crank-observability/src/correlation.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::fmt; - -use axum::http::HeaderMap; -use uuid::Uuid; - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct RequestId(String); - -impl RequestId { - pub const MAX_LEN: usize = 128; - const HEADER_NAME: &'static str = "x-request-id"; - - pub fn resolve(candidate: Option<&str>) -> Self { - candidate - .filter(|value| Self::is_valid(value)) - .map(|value| Self(value.to_owned())) - .unwrap_or_else(|| Self(Uuid::now_v7().to_string())) - } - - pub fn resolve_from_headers(headers: &HeaderMap) -> Self { - let mut values = headers.get_all(Self::HEADER_NAME).iter(); - let candidate = values.next(); - if values.next().is_some() { - return Self::resolve(None); - } - - Self::resolve(candidate.and_then(|value| value.to_str().ok())) - } - - pub fn is_valid(value: &str) -> bool { - !value.is_empty() - && value.len() <= Self::MAX_LEN - && value - .bytes() - .all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';') - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl fmt::Display for RequestId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} diff --git a/crates/crank-observability/src/error_reporting.rs b/crates/crank-observability/src/error_reporting.rs index fd18626..8b6bf68 100644 --- a/crates/crank-observability/src/error_reporting.rs +++ b/crates/crank-observability/src/error_reporting.rs @@ -1,7 +1,7 @@ use std::{ borrow::Cow, collections::BTreeMap, - env, fmt, + fmt, future::Future, time::{Duration, SystemTime}, }; @@ -13,11 +13,8 @@ use sentry::{ }; use thiserror::Error; -use crate::{ - RedactionLimits, ServiceIdentity, propagation::current_trace_id, redaction::truncate_string, -}; +use crate::{RedactionLimits, ServiceIdentity, propagation::current_trace_id}; -const SENTRY_DSN_ENV: &str = "CRANK_SENTRY_DSN"; const CRITICAL_ERROR_MESSAGE: &str = "critical error"; const SENTRY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); // Sentry serializes SystemTime as a finite f64; this keeps conservative fixed headroom. @@ -25,6 +22,7 @@ const MAX_SERIALIZED_TIMESTAMP_BYTES: usize = 32; tokio::task_local! { static REQUEST_ID: String; + static TRACE_ID: String; } pub struct SentryConfig { @@ -43,14 +41,6 @@ impl SentryConfig { Ok(Self { dsn: Some(dsn) }) } - pub fn from_env() -> Result { - match env::var(SENTRY_DSN_ENV) { - Ok(value) => Self::parse(Some(&value)), - Err(env::VarError::NotPresent) => Self::parse(None), - Err(env::VarError::NotUnicode(_)) => Err(SentryConfigError::InvalidEnvironmentEncoding), - } - } - pub fn enabled(&self) -> bool { self.dsn.is_some() } @@ -69,8 +59,6 @@ impl fmt::Debug for SentryConfig { pub enum SentryConfigError { #[error("CRANK_SENTRY_DSN is not a valid Sentry DSN")] InvalidDsn, - #[error("CRANK_SENTRY_DSN is not valid UTF-8")] - InvalidEnvironmentEncoding, #[error("critical error event budget cannot hold the required fields")] EventBudgetTooSmall, } @@ -123,11 +111,43 @@ pub fn capture_critical_error(category: CriticalErrorCategory) { }); } -pub async fn with_request_correlation(request_id: String, future: F) -> F::Output +pub async fn with_request_correlation( + request_id: String, + trace_id: String, + future: F, +) -> F::Output where F: Future, { - REQUEST_ID.scope(request_id, future).await + if !valid_request_id(&request_id) || !valid_trace_id(&trace_id) { + return future.await; + } + REQUEST_ID + .scope(request_id, TRACE_ID.scope(trace_id, future)) + .await +} + +fn valid_request_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| (0x21..=0x7e).contains(&byte) && byte != b',' && byte != b';') +} + +fn valid_trace_id(value: &str) -> bool { + value.len() == 32 + && value != "00000000000000000000000000000000" + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +pub fn current_request_correlation() -> (Option, Option) { + ( + REQUEST_ID.try_with(Clone::clone).ok(), + TRACE_ID.try_with(Clone::clone).ok(), + ) } pub(crate) fn init_sentry( @@ -184,16 +204,7 @@ fn sanitize_event( CriticalErrorCategory::Panic } }); - let mut tags = correlation_tags() - .into_iter() - .map(|(key, value)| (key, truncate_string(&value, limits.max_string_bytes))) - .collect::>(); - for key in ["request_id", "trace_id"] { - if let Some(value) = event.tags.get(key) { - tags.entry(key.to_owned()) - .or_insert_with(|| truncate_string(value, limits.max_string_bytes)); - } - } + let mut tags = correlation_tags().into_iter().collect::>(); tags.insert("service".to_owned(), identity.service().to_owned()); tags.insert("category".to_owned(), category.as_str().to_owned()); @@ -222,19 +233,21 @@ fn correlation_tags() -> BTreeMap { if let Ok(request_id) = REQUEST_ID.try_with(Clone::clone) { tags.insert("request_id".to_owned(), request_id); } - if let Some(trace_id) = current_trace_id() { + if let Ok(trace_id) = TRACE_ID.try_with(Clone::clone) { + tags.insert("trace_id".to_owned(), trace_id); + } else if let Some(trace_id) = current_trace_id() { tags.insert("trace_id".to_owned(), trace_id); } tags } -fn enforce_event_budget(mut event: Event<'static>, max_event_bytes: usize) -> Event<'static> { +fn enforce_event_budget(event: Event<'static>, max_event_bytes: usize) -> Event<'static> { if serialized_event_len(&event) <= max_event_bytes { return event; } - event.tags.remove("request_id"); - event.tags.remove("trace_id"); + // Startup validation reserves enough room for maximum canonical IDs. They + // are never evicted from a support event to satisfy a byte budget. debug_assert!(serialized_event_len(&event) <= max_event_bytes); event } @@ -257,7 +270,7 @@ fn required_critical_event_budget(identity: &ServiceIdentity, limits: RedactionL CriticalErrorCategory::ALL .into_iter() .map(|category| { - let event = sanitize_event( + let mut event = sanitize_event( Event { tags: BTreeMap::from([("category".to_owned(), category.as_str().to_owned())]), timestamp: SystemTime::UNIX_EPOCH, @@ -266,6 +279,8 @@ fn required_critical_event_budget(identity: &ServiceIdentity, limits: RedactionL identity, unbounded_limits, ); + event.tags.insert("request_id".to_owned(), "r".repeat(128)); + event.tags.insert("trace_id".to_owned(), "a".repeat(32)); serialized_event_len(&event) .saturating_add(MAX_SERIALIZED_TIMESTAMP_BYTES.saturating_sub(1)) }) @@ -439,11 +454,15 @@ mod tests { let events = sentry::test::with_captured_events_options( || { tracing::dispatcher::with_default(&dispatch, || { - runtime.block_on(with_request_correlation("request-123".to_owned(), async { - let span = tracing::info_span!(target: "crank::trace", "http.request"); - let _span_guard = span.enter(); - capture_critical_error(CriticalErrorCategory::DataIntegrity); - })); + runtime.block_on(with_request_correlation( + "request-123".to_owned(), + "0af7651916cd43dd8448eb211c80319c".to_owned(), + async { + let span = tracing::info_span!(target: "crank::trace", "http.request"); + let _span_guard = span.enter(); + capture_critical_error(CriticalErrorCategory::DataIntegrity); + }, + )); }); }, options, @@ -496,6 +515,7 @@ mod tests { tracing::dispatcher::with_default(&dispatch, || { runtime.block_on(with_request_correlation( "panic-request-123".to_owned(), + "0af7651916cd43dd8448eb211c80319c".to_owned(), async { let span = tracing::info_span!(target: "crank::trace", "http.request"); diff --git a/crates/crank-observability/src/lib.rs b/crates/crank-observability/src/lib.rs index 2d0f17e..1792179 100644 --- a/crates/crank-observability/src/lib.rs +++ b/crates/crank-observability/src/lib.rs @@ -1,5 +1,4 @@ mod config; -mod correlation; mod error_reporting; mod incidents; mod instrumentation; @@ -12,13 +11,12 @@ mod redaction; mod schema; pub use config::{ObservabilityConfig, ObservabilityConfigError, ServiceIdentity}; -pub use correlation::RequestId; pub use crank_metrics::{ DURATION_BUCKETS_SECONDS, MetricDefinition, MetricKind, MetricUnit, metric_schema, }; pub use error_reporting::{ CriticalErrorCategory, SentryConfig, SentryConfigError, capture_critical_error, - with_request_correlation, + current_request_correlation, with_request_correlation, }; pub use incidents::{OperationalIncident, operational_incident_total, record_operational_incident}; pub use instrumentation::{record_db_pool_connections, record_http_request}; diff --git a/crates/crank-observability/src/lifecycle.rs b/crates/crank-observability/src/lifecycle.rs index 6004a2d..6223c61 100644 --- a/crates/crank-observability/src/lifecycle.rs +++ b/crates/crank-observability/src/lifecycle.rs @@ -6,29 +6,45 @@ use tracing_subscriber::util::SubscriberInitExt; use crate::{ MetricsConfig, MetricsSurface, MetricsSurfaceError, ObservabilityConfig, ObservabilityConfigError, OtlpTraceConfig, OtlpTraceConfigError, OtlpTraceError, - RedactionLimitsError, SentryConfig, SentryConfigError, error_reporting::init_sentry, - instrumentation::register_metric_schema, logging::build_subscriber_with_tracer, - otlp::build_tracer_provider, prometheus::install_prometheus_recorder, + RedactionLimitsError, SentryConfig, SentryConfigError, + error_reporting::init_sentry, + instrumentation::register_metric_schema, + logging::build_subscriber_with_tracer, + otlp::{build_local_tracer_provider, build_tracer_provider}, + prometheus::install_prometheus_recorder, propagation::install_trace_context_propagator, }; #[must_use = "observability resources must be retained until process shutdown"] pub struct ObservabilityLifecycle { metrics_handle: metrics_exporter_prometheus::PrometheusHandle, - tracer_provider: Option, + _tracer_provider: opentelemetry_sdk::trace::SdkTracerProvider, + trace_export_enabled: bool, sentry_guard: Option, } impl ObservabilityLifecycle { pub fn init(config: ObservabilityConfig) -> Result { + Self::init_with_exporters( + config, + SentryConfig::parse(None)?, + OtlpTraceConfig::default(), + ) + } + + pub fn init_with_exporters( + config: ObservabilityConfig, + sentry_config: SentryConfig, + trace_config: OtlpTraceConfig, + ) -> Result { let identity = config.identity().clone(); let redaction_limits = config.redaction_limits(); - let sentry_config = SentryConfig::from_env()?; - let trace_config = OtlpTraceConfig::from_env()?; - let tracing = build_tracer_provider(&identity, &trace_config)?; - let tracer = tracing.as_ref().map(|(_, tracer)| tracer.clone()); + let exported_tracing = build_tracer_provider(&identity, &trace_config)?; + let trace_export_enabled = exported_tracing.is_some(); + let (tracer_provider, tracer) = + exported_tracing.unwrap_or_else(|| build_local_tracer_provider(&identity)); install_trace_context_propagator(); - build_subscriber_with_tracer(config, io::stdout, tracer)? + build_subscriber_with_tracer(config, io::stdout, Some(tracer))? .try_init() .map_err(|_| ObservabilityInitError::SubscriberAlreadyInitialized)?; let metrics_handle = install_prometheus_recorder(&identity)?; @@ -37,7 +53,8 @@ impl ObservabilityLifecycle { Ok(Self { metrics_handle, - tracer_provider: tracing.map(|(provider, _)| provider), + _tracer_provider: tracer_provider, + trace_export_enabled, sentry_guard, }) } @@ -47,7 +64,7 @@ impl ObservabilityLifecycle { } pub fn traces_enabled(&self) -> bool { - self.tracer_provider.is_some() + self.trace_export_enabled } pub fn critical_errors_enabled(&self) -> bool { @@ -77,6 +94,8 @@ pub enum ObservabilityInitError { InvalidRedactionLimits(#[from] RedactionLimitsError), #[error("invalid log filter")] InvalidFilter, + #[error("log event budget cannot hold canonical correlation fields")] + LogEventBudgetTooSmall, #[error("global tracing subscriber is already initialized")] SubscriberAlreadyInitialized, #[error(transparent)] diff --git a/crates/crank-observability/src/logging.rs b/crates/crank-observability/src/logging.rs index c96d6ad..00d0ba5 100644 --- a/crates/crank-observability/src/logging.rs +++ b/crates/crank-observability/src/logging.rs @@ -13,6 +13,7 @@ use tracing_subscriber::{ use crate::{ ObservabilityConfig, ObservabilityInitError, RedactionLimits, ServiceIdentity, + current_request_correlation, propagation::current_trace_id, redaction::{redact_value, truncate_string}, schema::LogEnvelope, @@ -38,6 +39,9 @@ where { let (identity, filter, limits) = config.into_parts(); limits.validate()?; + if required_correlated_log_budget(&identity) > limits.max_event_bytes { + return Err(ObservabilityInitError::LogEventBudgetTooSmall); + } let filter = EnvFilter::try_new(filter).map_err(|_| ObservabilityInitError::InvalidFilter)?; let formatter = JsonEventFormatter::new(identity, limits); let fmt_layer = tracing_subscriber::fmt::layer() @@ -58,6 +62,22 @@ where .with(otel_layer)) } +fn required_correlated_log_budget(identity: &ServiceIdentity) -> usize { + let envelope = LogEnvelope { + timestamp: "9999-12-31T23:59:59.999999999Z".to_owned(), + level: "ERROR".to_owned(), + service: identity.service().to_owned(), + version: identity.version().to_owned(), + environment: identity.environment().to_owned(), + target: "0123456789abcdef".to_owned(), + event: "0123456789abcdef".to_owned(), + request_id: Some("r".repeat(128)), + trace_id: Some("a".repeat(32)), + fields: Map::from_iter([("truncated".to_owned(), Value::Bool(true))]), + }; + serde_json::to_vec(&envelope).map_or(usize::MAX, |value| value.len().saturating_add(1)) +} + #[derive(Clone, Debug)] struct JsonEventFormatter { identity: ServiceIdentity, @@ -75,10 +95,10 @@ impl JsonEventFormatter { event.record(&mut visitor); let mut raw_fields = visitor.fields; let request_id = take_correlation_id(&mut raw_fields, "request_id") - .map(|value| truncate_string(&value, self.limits.max_string_bytes)); + .or_else(|| current_request_correlation().0); let trace_id = take_correlation_id(&mut raw_fields, "trace_id") - .or_else(current_trace_id) - .map(|value| truncate_string(&value, self.limits.max_string_bytes)); + .or_else(|| current_request_correlation().1) + .or_else(current_trace_id); let cleaned = redact_value(&Value::Object(raw_fields), self.limits); let fields = cleaned.as_object().cloned().unwrap_or_default(); let timestamp = OffsetDateTime::now_utc() @@ -110,19 +130,11 @@ impl JsonEventFormatter { let fallback_string_limit = self.limits.max_string_bytes.min(64); envelope.target = truncate_string(&envelope.target, fallback_string_limit); envelope.event = truncate_string(&envelope.event, fallback_string_limit); - envelope.request_id = envelope - .request_id - .map(|value| truncate_string(&value, fallback_string_limit)); - envelope.trace_id = envelope - .trace_id - .map(|value| truncate_string(&value, fallback_string_limit)); let serialized = serde_json::to_string(&envelope).map_err(|_| fmt::Error)?; if serialized.len() <= line_budget { return Ok(serialized); } - envelope.request_id = None; - envelope.trace_id = None; envelope.target = truncate_string(&envelope.target, 16); envelope.event = truncate_string(&envelope.event, 16); let serialized = serde_json::to_string(&envelope).map_err(|_| fmt::Error)?; @@ -202,14 +214,27 @@ impl Visit for JsonFieldVisitor { } fn take_correlation_id(fields: &mut Map, name: &str) -> Option { - let value = fields.remove(name)?; - let value = match value { - Value::String(value) => value, - Value::Number(value) => value.to_string(), - Value::Bool(value) => value.to_string(), - Value::Null | Value::Array(_) | Value::Object(_) => return None, + let Value::String(value) = fields.remove(name)? else { + return None; }; - (!value.is_empty()).then_some(value) + let valid = match name { + "trace_id" => { + value.len() == 32 + && value != "00000000000000000000000000000000" + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + } + "request_id" | "correlation_id" => { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b',' && byte != b';') + } + _ => false, + }; + valid.then_some(value) } fn is_correlation_field(name: &str) -> bool { diff --git a/crates/crank-observability/src/otlp.rs b/crates/crank-observability/src/otlp.rs index 705bb83..b512370 100644 --- a/crates/crank-observability/src/otlp.rs +++ b/crates/crank-observability/src/otlp.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, env, fmt, time::Duration}; +use std::{collections::HashMap, fmt, time::Duration}; use axum::http::{HeaderName, HeaderValue}; use opentelemetry::{ @@ -118,8 +118,36 @@ impl fmt::Debug for OtlpTraceConfig { } impl OtlpTraceConfig { - pub fn from_env() -> Result { - OtlpEnvSettings::from_env()?.into_config() + #[allow(clippy::too_many_arguments)] + pub fn from_values( + generic_endpoint: Option, + traces_endpoint: Option, + generic_protocol: Option, + traces_protocol: Option, + generic_timeout: Option, + traces_timeout: Option, + generic_headers: Option, + traces_headers: Option, + max_queue_size: usize, + max_export_batch_size: usize, + scheduled_delay: String, + batch_export_timeout: String, + ) -> Result { + OtlpEnvSettings { + traces_endpoint, + generic_endpoint, + traces_protocol, + generic_protocol, + traces_timeout, + generic_timeout, + traces_headers, + generic_headers, + max_queue_size: Some(max_queue_size.to_string()), + max_export_batch_size: Some(max_export_batch_size.to_string()), + scheduled_delay: Some(scheduled_delay), + batch_export_timeout: Some(batch_export_timeout), + } + .into_config() } fn from_settings(settings: OtlpEnvSettings) -> Result { @@ -232,6 +260,17 @@ impl OtlpTraceConfig { } } +impl Default for OtlpTraceConfig { + fn default() -> Self { + Self { + endpoint: None, + export_timeout: DEFAULT_EXPORT_TIMEOUT, + batch: OtlpBatchConfig::default(), + headers: HashMap::new(), + } + } +} + #[derive(Default)] struct OtlpEnvSettings { traces_endpoint: Option, @@ -249,23 +288,6 @@ struct OtlpEnvSettings { } impl OtlpEnvSettings { - fn from_env() -> Result { - Ok(Self { - traces_endpoint: optional_env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")?, - generic_endpoint: optional_env("OTEL_EXPORTER_OTLP_ENDPOINT")?, - traces_protocol: optional_env("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")?, - generic_protocol: optional_env("OTEL_EXPORTER_OTLP_PROTOCOL")?, - traces_timeout: optional_env("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT")?, - generic_timeout: optional_env("OTEL_EXPORTER_OTLP_TIMEOUT")?, - traces_headers: optional_env("OTEL_EXPORTER_OTLP_TRACES_HEADERS")?, - generic_headers: optional_env("OTEL_EXPORTER_OTLP_HEADERS")?, - max_queue_size: optional_env("OTEL_BSP_MAX_QUEUE_SIZE")?, - max_export_batch_size: optional_env("OTEL_BSP_MAX_EXPORT_BATCH_SIZE")?, - scheduled_delay: optional_env("OTEL_BSP_SCHEDULE_DELAY")?, - batch_export_timeout: optional_env("OTEL_BSP_EXPORT_TIMEOUT")?, - }) - } - fn into_config(self) -> Result { OtlpTraceConfig::from_settings(self) } @@ -313,7 +335,28 @@ pub fn build_tracer_provider( let processor = BatchSpanProcessor::builder(ObservedSpanExporter(exporter)) .with_batch_config(config.batch.sdk_config()) .build(); - let resource = Resource::builder_empty() + let resource = trace_resource(identity); + let provider = SdkTracerProvider::builder() + .with_span_processor(processor) + .with_resource(resource) + .build(); + let tracer = provider.tracer("crank"); + + Ok(Some((provider, tracer))) +} + +pub(crate) fn build_local_tracer_provider( + identity: &ServiceIdentity, +) -> (SdkTracerProvider, SdkTracer) { + let provider = SdkTracerProvider::builder() + .with_resource(trace_resource(identity)) + .build(); + let tracer = provider.tracer("crank"); + (provider, tracer) +} + +fn trace_resource(identity: &ServiceIdentity) -> Resource { + Resource::builder_empty() .with_attributes([ KeyValue::new("service.name", identity.service().to_owned()), KeyValue::new("service.version", identity.version().to_owned()), @@ -322,14 +365,7 @@ pub fn build_tracer_provider( identity.environment().to_owned(), ), ]) - .build(); - let provider = SdkTracerProvider::builder() - .with_span_processor(processor) - .with_resource(resource) - .build(); - let tracer = provider.tracer("crank"); - - Ok(Some((provider, tracer))) + .build() } #[derive(Debug)] @@ -405,7 +441,7 @@ fn is_allowed_span_attribute(attribute: &KeyValue) -> bool { }; let value = value.as_str(); match attribute.key.as_str() { - "request_id" => crate::RequestId::is_valid(value), + "request_id" => is_valid_request_id(value), "stage" => is_allowed_span_name(value), "outcome" => matches!( value, @@ -453,6 +489,14 @@ fn is_allowed_span_attribute(attribute: &KeyValue) -> bool { } } +fn is_valid_request_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b',' && byte != b';') +} + #[derive(Clone, Copy)] enum EndpointKind { Trace, @@ -514,17 +558,6 @@ fn parse_headers(value: &str) -> Result, OtlpTraceConfig }) } -fn optional_env(field: &'static str) -> Result, OtlpTraceConfigError> { - match env::var(field) { - Ok(value) if value.is_empty() => Ok(None), - Ok(value) => Ok(Some(value)), - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => { - Err(OtlpTraceConfigError::InvalidEnvironmentEncoding { field }) - } - } -} - fn usize_env( field: &'static str, value: Option, @@ -586,7 +619,10 @@ mod tests { use tracing::{Instrument, info_span}; use tracing_subscriber::layer::SubscriberExt; - use super::{OtlpBatchConfig, OtlpEnvSettings, OtlpTraceConfig, build_tracer_provider}; + use super::{ + OtlpBatchConfig, OtlpEnvSettings, OtlpTraceConfig, build_local_tracer_provider, + build_tracer_provider, + }; use crate::ServiceIdentity; #[test] @@ -708,6 +744,15 @@ mod tests { assert!(build_tracer_provider(&identity, &config).unwrap().is_none()); } + #[test] + fn local_provider_creates_valid_context_without_an_exporter() { + let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "test").unwrap(); + let (provider, tracer) = build_local_tracer_provider(&identity); + let span = tracer.start("http.request"); + assert!(span.span_context().is_valid()); + provider.shutdown().unwrap(); + } + #[test] fn real_http_protobuf_export_contains_resource_and_trace() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/crank-observability/src/prometheus.rs b/crates/crank-observability/src/prometheus.rs index 689c25b..d3a701d 100644 --- a/crates/crank-observability/src/prometheus.rs +++ b/crates/crank-observability/src/prometheus.rs @@ -1,4 +1,4 @@ -use std::{env, net::SocketAddr}; +use std::net::SocketAddr; use axum::{ Router, @@ -19,8 +19,6 @@ use tokio::net::TcpListener; use crate::{DURATION_BUCKETS_SECONDS, ServiceIdentity}; -const METRICS_ENABLED_ENV: &str = "CRANK_METRICS_ENABLED"; -const METRICS_TOKEN_ENV: &str = "CRANK_METRICS_BEARER_TOKEN"; const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; #[derive(Clone)] @@ -62,33 +60,6 @@ impl MetricsConfig { }) } - pub fn from_env( - bind_env: &'static str, - default_bind: SocketAddr, - ) -> Result { - let enabled = parse_enabled(env::var(METRICS_ENABLED_ENV))?; - let bind_addr = match env::var(bind_env) { - Ok(raw) => raw - .parse() - .map_err(|_| MetricsConfigError::InvalidBindAddress { field: bind_env })?, - Err(env::VarError::NotPresent) => default_bind, - Err(env::VarError::NotUnicode(_)) => { - return Err(MetricsConfigError::InvalidEnvironmentEncoding { field: bind_env }); - } - }; - let bearer_token = match env::var(METRICS_TOKEN_ENV) { - Ok(token) => Some(token), - Err(env::VarError::NotPresent) => None, - Err(env::VarError::NotUnicode(_)) => { - return Err(MetricsConfigError::InvalidEnvironmentEncoding { - field: METRICS_TOKEN_ENV, - }); - } - }; - - Self::new(enabled, bind_addr, bearer_token) - } - pub fn enabled(&self) -> bool { self.enabled } @@ -280,17 +251,3 @@ fn bearer_token(headers: &HeaderMap) -> Option<&[u8]> { fn token_digest(token: &[u8]) -> [u8; 32] { Sha256::digest(token).into() } - -fn parse_enabled(value: Result) -> Result { - match value { - Ok(raw) => match raw.to_ascii_lowercase().as_str() { - "true" | "1" => Ok(true), - "false" | "0" => Ok(false), - _ => Err(MetricsConfigError::InvalidEnabledFlag), - }, - Err(env::VarError::NotPresent) => Ok(true), - Err(env::VarError::NotUnicode(_)) => Err(MetricsConfigError::InvalidEnvironmentEncoding { - field: METRICS_ENABLED_ENV, - }), - } -} diff --git a/crates/crank-observability/tests/correlation.rs b/crates/crank-observability/tests/correlation.rs deleted file mode 100644 index 1404c56..0000000 --- a/crates/crank-observability/tests/correlation.rs +++ /dev/null @@ -1,74 +0,0 @@ -use axum::http::{HeaderMap, HeaderValue}; -use crank_observability::RequestId; -use uuid::Version; - -#[test] -fn preserves_valid_opaque_request_id() { - let request_id = RequestId::resolve(Some("req_test-123/abc")); - - assert_eq!(request_id.as_str(), "req_test-123/abc"); -} - -#[test] -fn replaces_missing_and_invalid_values_with_uuid_v7() { - for candidate in [ - None, - Some(""), - Some("bad value"), - Some(" leading"), - Some("trailing "), - Some("bad,value"), - Some("bad;value"), - Some("я"), - ] { - let request_id = RequestId::resolve(candidate); - let parsed = uuid::Uuid::parse_str(request_id.as_str()).expect("generated UUID"); - - assert_eq!(parsed.get_version(), Some(Version::SortRand)); - } -} - -#[test] -fn rejects_values_over_the_shared_limit() { - let oversized = "x".repeat(RequestId::MAX_LEN + 1); - let request_id = RequestId::resolve(Some(&oversized)); - - assert_ne!(request_id.as_str(), oversized); - assert_eq!( - uuid::Uuid::parse_str(request_id.as_str()) - .expect("generated UUID") - .get_version(), - Some(Version::SortRand) - ); -} - -#[test] -fn resolves_exactly_one_header_value_and_rejects_ambiguous_values() { - let mut single = HeaderMap::new(); - single.insert( - "x-request-id", - HeaderValue::from_static("opaque-request-id"), - ); - - assert_eq!( - RequestId::resolve_from_headers(&single).as_str(), - "opaque-request-id" - ); - - let mut ambiguous = HeaderMap::new(); - ambiguous.append("x-request-id", HeaderValue::from_static("first-request-id")); - ambiguous.append( - "x-request-id", - HeaderValue::from_static("second-request-id"), - ); - - let generated = RequestId::resolve_from_headers(&ambiguous); - assert_ne!(generated.as_str(), "first-request-id"); - assert_ne!(generated.as_str(), "second-request-id"); - assert_eq!( - uuid::Uuid::parse_str(generated.as_str()) - .expect("generated UUID") - .get_version(), - Some(Version::SortRand) - ); -} diff --git a/crates/crank-observability/tests/json_logging.rs b/crates/crank-observability/tests/json_logging.rs index a403668..eb1b4c0 100644 --- a/crates/crank-observability/tests/json_logging.rs +++ b/crates/crank-observability/tests/json_logging.rs @@ -132,11 +132,11 @@ fn correlation_fields_are_distinct_and_only_present_when_recorded() { tracing::info!( name: "admin.request.completed", request_id = "req-123", - trace_id = "trace-456" + trace_id = "0af7651916cd43dd8448eb211c80319c" ); }); assert_eq!(present[0]["request_id"], "req-123"); - assert_eq!(present[0]["trace_id"], "trace-456"); + assert_eq!(present[0]["trace_id"], "0af7651916cd43dd8448eb211c80319c"); assert!(present[0]["fields"].get("request_id").is_none()); assert!(present[0]["fields"].get("trace_id").is_none()); @@ -148,7 +148,7 @@ fn correlation_fields_are_distinct_and_only_present_when_recorded() { } #[test] -fn correlation_fields_preserve_scalar_display_values_before_field_limits() { +fn correlation_fields_accept_valid_strings_and_reject_non_string_values() { let limits = RedactionLimits { max_object_fields: 1, ..RedactionLimits::default() @@ -164,7 +164,7 @@ fn correlation_fields_preserve_scalar_display_values_before_field_limits() { }); assert_eq!(events[0]["request_id"], "123"); - assert_eq!(events[0]["trace_id"], "true"); + assert!(events[0].get("trace_id").is_none()); } #[test] @@ -321,7 +321,7 @@ fn subscriber_rejects_limits_that_cannot_hold_an_event() { } #[test] -fn minimum_event_budget_handles_maximum_identity_labels() { +fn event_budget_rejects_maximum_identity_labels_when_correlation_cannot_fit() { let writer = SharedWriter::default(); let config = ObservabilityConfig::new( ServiceIdentity::try_new("s".repeat(64), "v".repeat(64), "e".repeat(64)) @@ -332,20 +332,7 @@ fn minimum_event_budget_handles_maximum_identity_labels() { ..RedactionLimits::default() }, ); - let subscriber = - build_subscriber(config, writer.clone()).expect("minimum valid budget must be usable"); - - tracing::subscriber::with_default(subscriber, || { - tracing::info!( - name: "event-name-that-is-intentionally-longer-than-the-fallback-limit", - description = %"x".repeat(4096), - ); - }); - - let output = writer.output(); - assert!(output.len() <= 512); - assert_eq!(output.lines().count(), 1); - serde_json::from_str::(output.trim_end()).expect("bounded line must remain valid JSON"); + assert!(build_subscriber(config, writer).is_err()); } #[test] diff --git a/crates/crank-observability/tests/prometheus.rs b/crates/crank-observability/tests/prometheus.rs index 2ebe971..efd3784 100644 --- a/crates/crank-observability/tests/prometheus.rs +++ b/crates/crank-observability/tests/prometheus.rs @@ -97,6 +97,8 @@ fn schema_is_closed_and_uses_fixed_duration_buckets() { "agent_id", "operation_id", "request_id", + "trace_id", + "correlation_id", "url", "error_message", "text", diff --git a/crates/crank-observability/tests/request_correlation.rs b/crates/crank-observability/tests/request_correlation.rs new file mode 100644 index 0000000..747c9d1 --- /dev/null +++ b/crates/crank-observability/tests/request_correlation.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use crank_observability::{current_request_correlation, with_request_correlation}; + +#[tokio::test] +async fn concurrent_request_correlation_is_task_local() { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let first = observe( + "request-first", + "0af7651916cd43dd8448eb211c80319c", + Arc::clone(&barrier), + ); + let second = observe( + "request-second", + "1af7651916cd43dd8448eb211c80319c", + barrier, + ); + let (first, second) = tokio::join!(first, second); + + assert_eq!( + first, + ( + Some("request-first".to_owned()), + Some("0af7651916cd43dd8448eb211c80319c".to_owned()), + ) + ); + assert_eq!( + second, + ( + Some("request-second".to_owned()), + Some("1af7651916cd43dd8448eb211c80319c".to_owned()), + ) + ); + assert_eq!(current_request_correlation(), (None, None)); +} + +#[tokio::test] +async fn invalid_correlation_strings_never_enter_task_local_state() { + let observed = with_request_correlation( + "bad request id".to_owned(), + "CANARY-NOT-A-TRACE-ID".to_owned(), + async { current_request_correlation() }, + ) + .await; + + assert_eq!(observed, (None, None)); +} + +async fn observe( + request_id: &str, + trace_id: &str, + barrier: Arc, +) -> (Option, Option) { + with_request_correlation(request_id.to_owned(), trace_id.to_owned(), async move { + barrier.wait().await; + tokio::task::yield_now().await; + current_request_correlation() + }) + .await +} diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 876eac5..a76c4e8 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -2,6 +2,8 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum RegistryError { + #[error(transparent)] + Migration(#[from] crate::migrations::MigrationError), #[error(transparent)] Storage(#[from] sqlx::Error), #[error(transparent)] @@ -80,4 +82,6 @@ pub enum RegistryError { InvalidEnumRepresentation { field: &'static str }, #[error("invalid numeric value for field {field}: {value}")] InvalidNumericValue { field: &'static str, value: i64 }, + #[error("invalid correlation identity for field {field}")] + InvalidCorrelationIdentity { field: &'static str }, } diff --git a/crates/crank-registry/src/ext.rs b/crates/crank-registry/src/ext.rs index 6c6aabe..1b69adb 100644 --- a/crates/crank-registry/src/ext.rs +++ b/crates/crank-registry/src/ext.rs @@ -1,77 +1,14 @@ -use std::sync::Arc; - -use sqlx::{PgPool, query}; - -use crate::RegistryError; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ExtensionMigration { pub version: u32, - pub sql: &'static str, + pub checksum: &'static str, + pub source_digest: &'static str, + pub phase: &'static str, + pub compatibility: &'static str, + pub owner: &'static str, } pub trait RegistryExtension: Send + Sync { fn name(&self) -> &str; fn migrations(&self) -> &[ExtensionMigration]; } - -pub async fn apply_extension_migrations( - pool: &PgPool, - extensions: &[Arc], -) -> Result<(), RegistryError> { - query( - "create table if not exists __crank_ext_migrations ( - extension_name text not null, - version integer not null, - applied_at timestamptz not null default now(), - primary key (extension_name, version) - )", - ) - .execute(pool) - .await?; - - for extension in extensions { - for migration in extension.migrations() { - let already_applied = query( - "select 1 - from __crank_ext_migrations - where extension_name = $1 and version = $2", - ) - .bind(extension.name()) - .bind(i32::try_from(migration.version).map_err(|_| { - RegistryError::InvalidNumericValue { - field: "extension_migration.version", - value: migration.version as i64, - } - })?) - .fetch_optional(pool) - .await? - .is_some(); - - if already_applied { - continue; - } - - let mut tx = pool.begin().await?; - - query(migration.sql).execute(&mut *tx).await?; - query( - "insert into __crank_ext_migrations (extension_name, version) - values ($1, $2)", - ) - .bind(extension.name()) - .bind(i32::try_from(migration.version).map_err(|_| { - RegistryError::InvalidNumericValue { - field: "extension_migration.version", - value: migration.version as i64, - } - })?) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - } - } - - Ok(()) -} diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index 945a693..693478a 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -5,7 +5,11 @@ mod model; mod postgres; pub use error::RegistryError; -pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}; +pub use ext::{ExtensionMigration, RegistryExtension}; +pub use migrations::{ + BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor, + MigrationError, MigrationPreflight, +}; pub mod records { pub use crate::model::{ @@ -39,8 +43,12 @@ pub mod requests { } pub mod infrastructure { - pub use crate::ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}; + pub use crate::ext::{ExtensionMigration, RegistryExtension}; pub use crate::postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry}; + pub use crate::{ + BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, + MigrationDescriptor, MigrationError, MigrationPreflight, + }; } pub use model::{ diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 4b950b7..44c917b 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -1,721 +1,10 @@ -use sqlx::{PgPool, Postgres, Row, Transaction, query}; +mod authority; +mod baseline_v1; +mod schema_guard; -const CORE_MIGRATION_LOCK_ID: i64 = 0x43_52_41_4E_4B; -const BASELINE_VERSION: i32 = 1; -const BASELINE_CHECKSUM: &str = "crank-community-baseline-v1"; +pub use authority::{ + BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor, + MigrationError, MigrationPreflight, +}; -pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { - let mut transaction = pool.begin().await?; - query("select pg_advisory_xact_lock($1)") - .bind(CORE_MIGRATION_LOCK_ID) - .execute(&mut *transaction) - .await?; - query( - "create table if not exists __crank_core_migrations ( - version integer primary key, - description text not null, - checksum text not null, - applied_at timestamptz not null default now() - )", - ) - .execute(&mut *transaction) - .await?; - - let applied = query( - "select version, checksum - from __crank_core_migrations - order by version", - ) - .fetch_all(&mut *transaction) - .await?; - for row in &applied { - let version = row.try_get::("version")?; - let checksum = row.try_get::("checksum")?; - if version != BASELINE_VERSION || checksum != BASELINE_CHECKSUM { - return Err(sqlx::Error::Protocol(format!( - "unsupported or modified core migration: version={version}, checksum={checksum}" - ))); - } - } - - if applied.is_empty() { - apply_baseline(&mut transaction).await?; - query( - "insert into __crank_core_migrations (version, description, checksum) - values ($1, $2, $3)", - ) - .bind(BASELINE_VERSION) - .bind("community baseline") - .bind(BASELINE_CHECKSUM) - .execute(&mut *transaction) - .await?; - } - - transaction.commit().await?; - Ok(()) -} - -async fn apply_baseline(transaction: &mut Transaction<'_, Postgres>) -> Result<(), sqlx::Error> { - query( - "create table if not exists workspaces ( - id text primary key, - slug text not null unique, - display_name text not null, - status text not null, - settings_json jsonb not null default '{}'::jsonb, - created_at timestamptz not null, - updated_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists users ( - id text primary key, - email text not null unique, - display_name text not null, - password_hash text null, - status text not null, - created_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query("alter table users add column if not exists password_hash text null") - .execute(&mut **transaction) - .await?; - - query( - "insert into users ( - id, - email, - display_name, - status, - created_at - ) values ( - 'user_default_owner', - 'owner@crank.local', - 'Workspace Owner', - 'active', - now() - ) - on conflict (id) do nothing", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists memberships ( - workspace_id text not null references workspaces(id) on delete cascade, - user_id text not null references users(id) on delete cascade, - role text not null, - created_at timestamptz not null, - primary key (workspace_id, user_id) - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists user_sessions ( - id text primary key, - user_id text not null references users(id) on delete cascade, - current_workspace_id text null references workspaces(id) on delete set null, - secret_hash text not null, - status text not null, - expires_at timestamptz not null, - last_seen_at timestamptz null, - created_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "alter table user_sessions - add column if not exists current_workspace_id text null references workspaces(id) on delete set null", - ) - .execute(&mut **transaction) - .await?; - - query( - "insert into workspaces ( - id, - slug, - display_name, - status, - settings_json, - created_at, - updated_at - ) values ( - 'ws_default', - 'default', - 'Default Workspace', - 'active', - '{}'::jsonb, - now(), - now() - ) - on conflict (id) do nothing", - ) - .execute(&mut **transaction) - .await?; - - query( - "insert into memberships ( - workspace_id, - user_id, - role, - created_at - ) values ( - 'ws_default', - 'user_default_owner', - 'owner', - now() - ) - on conflict (workspace_id, user_id) do nothing", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists invitation_tokens ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - email text not null, - role text not null, - status text not null, - token_hash text not null, - expires_at timestamptz not null, - created_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists platform_api_keys ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - agent_id text null, - name text not null, - prefix text not null, - secret_hash text not null, - key_kind text not null default 'mcp_client', - scopes_json jsonb not null, - status text not null, - created_at timestamptz not null, - last_used_at timestamptz null, - revoked_at timestamptz null, - expires_at timestamptz null, - allowed_origins_json jsonb not null default '[]'::jsonb - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)", - ) - .execute(&mut **transaction) - .await?; - query("alter table platform_api_keys add column if not exists agent_id text null") - .execute(&mut **transaction) - .await?; - query( - "alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'", - ) - .execute(&mut **transaction) - .await?; - query("alter table platform_api_keys add column if not exists expires_at timestamptz null") - .execute(&mut **transaction) - .await?; - query( - "alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb", - ) - .execute(&mut **transaction) - .await?; - - query( - "insert into workspaces ( - id, - slug, - display_name, - status, - settings_json, - created_at, - updated_at - ) values ( - 'ws_default', - 'default', - 'Default Workspace', - 'active', - '{}'::jsonb, - now(), - now() - ) - on conflict (id) do nothing", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists operations ( - id text primary key, - workspace_id text null references workspaces(id) on delete cascade, - name text not null, - display_name text not null, - category text not null default 'general', - protocol text not null, - security_level text not null default 'standard', - status text not null, - current_draft_version integer not null default 1, - latest_published_version integer null, - created_at timestamptz not null, - updated_at timestamptz not null, - published_at timestamptz null - )", - ) - .execute(&mut **transaction) - .await?; - - query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade") - .execute(&mut **transaction) - .await?; - query( - "alter table operations add column if not exists category text not null default 'general'", - ) - .execute(&mut **transaction) - .await?; - query( - "alter table operations add column if not exists security_level text not null default 'standard'", - ) - .execute(&mut **transaction) - .await?; - query("update operations set workspace_id = 'ws_default' where workspace_id is null") - .execute(&mut **transaction) - .await?; - query("alter table operations alter column workspace_id set not null") - .execute(&mut **transaction) - .await?; - query("alter table operations drop constraint if exists operations_name_key") - .execute(&mut **transaction) - .await?; - query( - "create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists operation_versions ( - operation_id text not null references operations(id) on delete cascade, - version integer not null, - status text not null, - target_json jsonb not null, - input_schema_json jsonb not null, - output_schema_json jsonb not null, - input_mapping_json jsonb not null, - output_mapping_json jsonb not null, - execution_config_json jsonb not null, - tool_description_json jsonb not null, - samples_json jsonb null, - generated_draft_json jsonb null, - config_export_json jsonb null, - wizard_state_json jsonb null, - change_note text null, - created_at timestamptz not null, - created_by text null, - primary key (operation_id, version) - )", - ) - .execute(&mut **transaction) - .await?; - - query("alter table operation_versions add column if not exists wizard_state_json jsonb null") - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists published_operations ( - operation_id text primary key references operations(id) on delete cascade, - version integer not null, - published_at timestamptz not null, - published_by text null, - foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists operation_samples ( - id text primary key, - operation_id text not null references operations(id) on delete cascade, - version integer not null, - sample_kind text not null, - storage_ref text not null, - content_type text not null, - file_name text null, - created_at timestamptz not null, - foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists descriptors ( - id text primary key, - operation_id text null references operations(id) on delete cascade, - version integer null, - descriptor_kind text not null, - storage_ref text not null, - source_name text null, - package_index_json jsonb null, - created_at timestamptz not null, - foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists auth_profiles ( - id text primary key, - workspace_id text null references workspaces(id) on delete cascade, - name text not null, - kind text not null, - config_json jsonb not null, - created_at timestamptz not null, - updated_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade") - .execute(&mut **transaction) - .await?; - query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null") - .execute(&mut **transaction) - .await?; - query("alter table auth_profiles alter column workspace_id set not null") - .execute(&mut **transaction) - .await?; - query("alter table auth_profiles drop constraint if exists auth_profiles_name_key") - .execute(&mut **transaction) - .await?; - query( - "create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists workspace_upstreams ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - name text not null, - base_url text not null, - static_headers_json jsonb not null default '{}'::jsonb, - auth_profile_id text null references auth_profiles(id) on delete set null, - created_at timestamptz not null, - updated_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - query( - "create unique index if not exists workspace_upstreams_workspace_name_idx on workspace_upstreams(workspace_id, name)", - ) - .execute(&mut **transaction) - .await?; - query( - "create unique index if not exists workspace_upstreams_workspace_base_auth_idx on workspace_upstreams(workspace_id, base_url, coalesce(auth_profile_id, ''))", - ) - .execute(&mut **transaction) - .await?; - query( - "insert into workspace_upstreams ( - id, - workspace_id, - name, - base_url, - static_headers_json, - auth_profile_id, - created_at, - updated_at - ) - select - 'upstream_frankfurter_' || w.id, - w.id, - 'Frankfurter', - 'https://api.frankfurter.dev', - '{}'::jsonb, - null, - now(), - now() - from workspaces w - where not exists ( - select 1 - from workspace_upstreams wu - where wu.workspace_id = w.id - and wu.name = 'Frankfurter' - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists secrets ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - name text not null, - kind text not null, - status text not null, - current_version integer not null, - last_used_at timestamptz null, - created_at timestamptz not null, - updated_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists secret_versions ( - secret_id text not null references secrets(id) on delete cascade, - version integer not null, - ciphertext text not null, - key_version text not null, - created_at timestamptz not null, - created_by text null references users(id) on delete set null, - primary key (secret_id, version) - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists yaml_import_jobs ( - id text primary key, - source_sample_id text null references operation_samples(id) on delete set null, - status text not null, - format_version text not null, - mode text not null, - result_operation_id text null references operations(id) on delete set null, - result_version integer null, - error_text text null, - created_at timestamptz not null, - finished_at timestamptz null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists import_jobs ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - kind text not null, - source_format text not null, - source_version text null, - status text not null, - preview_payload jsonb not null, - created_operation_ids jsonb not null default '[]'::jsonb, - error_text text null, - created_at timestamptz not null, - expires_at timestamptz not null, - finished_at timestamptz null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists agents ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - slug text not null, - display_name text not null, - description text not null, - status text not null, - current_draft_version integer not null default 1, - latest_published_version integer null, - created_at timestamptz not null, - updated_at timestamptz not null, - published_at timestamptz null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists agent_versions ( - agent_id text not null references agents(id) on delete cascade, - version integer not null, - status text not null, - instructions_json jsonb not null, - tool_selection_policy_json jsonb not null, - created_at timestamptz not null, - primary key (agent_id, version) - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists agent_operation_bindings ( - agent_id text not null references agents(id) on delete cascade, - agent_version integer not null, - operation_id text not null references operations(id) on delete cascade, - operation_version integer not null, - tool_name text not null, - tool_title text not null, - tool_description_override text null, - enabled boolean not null default true, - foreign key (agent_id, agent_version) references agent_versions(agent_id, version) on delete cascade, - foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create unique index if not exists agent_bindings_tool_name_idx on agent_operation_bindings(agent_id, agent_version, tool_name)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists published_agents ( - agent_id text primary key references agents(id) on delete cascade, - version integer not null, - published_at timestamptz not null, - published_by text null, - foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists approval_requests ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - agent_id text not null references agents(id) on delete cascade, - operation_id text not null references operations(id) on delete cascade, - operation_version integer not null, - status text not null, - risk_level text not null, - request_payload_json jsonb not null, - response_payload_json jsonb null, - created_at timestamptz not null, - expires_at timestamptz not null, - decided_at timestamptz null, - decided_by_key_id text null references platform_api_keys(id) on delete set null, - decision_note text null - )", - ) - .execute(&mut **transaction) - .await?; - - query("alter table approval_requests add column if not exists execution_started_at timestamptz null") - .execute(&mut **transaction) - .await?; - query("alter table approval_requests add column if not exists execution_attempts integer not null default 0") - .execute(&mut **transaction) - .await?; - query("alter table approval_requests add column if not exists request_fingerprint text null") - .execute(&mut **transaction) - .await?; - query( - "create unique index if not exists approval_requests_pending_fingerprint_idx - on approval_requests(agent_id, operation_id, operation_version, request_fingerprint) - where status = 'pending' and request_fingerprint is not null", - ) - .execute(&mut **transaction) - .await?; - query( - "create index if not exists approval_requests_agent_status_idx - on approval_requests(workspace_id, agent_id, status, expires_at)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists invocation_logs ( - id text primary key, - workspace_id text not null references workspaces(id) on delete cascade, - agent_id text null references agents(id) on delete set null, - operation_id text not null references operations(id) on delete cascade, - source text not null, - level text not null, - status text not null, - tool_name text not null, - message text not null, - request_id text null, - status_code integer null, - duration_ms bigint not null, - error_kind text null, - request_preview_json jsonb not null, - response_preview_json jsonb not null, - created_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - query( - "create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create index if not exists invocation_logs_workspace_operation_created_idx on invocation_logs(workspace_id, operation_id, created_at desc)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create index if not exists invocation_logs_workspace_agent_created_idx on invocation_logs(workspace_id, agent_id, created_at desc)", - ) - .execute(&mut **transaction) - .await?; - - query( - "create table if not exists usage_rollups ( - workspace_id text not null references workspaces(id) on delete cascade, - agent_id text null references agents(id) on delete cascade, - operation_id text null references operations(id) on delete cascade, - period text not null, - calls_total bigint not null, - calls_ok bigint not null, - calls_error bigint not null, - p50_ms bigint not null, - p95_ms bigint not null, - p99_ms bigint not null, - updated_at timestamptz not null - )", - ) - .execute(&mut **transaction) - .await?; - - Ok(()) -} +use baseline_v1::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline}; diff --git a/crates/crank-registry/src/migrations/authority.rs b/crates/crank-registry/src/migrations/authority.rs new file mode 100644 index 0000000..d6d14fd --- /dev/null +++ b/crates/crank-registry/src/migrations/authority.rs @@ -0,0 +1,926 @@ +use std::fmt; + +use sha2::{Digest, Sha256}; +use sqlx::{PgConnection, PgPool, Row, Transaction, query}; + +use super::schema_guard::{ + OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint, +}; +use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline}; +use crate::ext::ExtensionMigration; + +const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947; +const CURRENT_VERSION: i64 = 3; +const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3]; +const BASELINE_SOURCE_SHA256: &str = + "eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675"; +const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql"); +const CONSOLIDATION_SOURCE_SHA256: &str = + "1908b97ca7fe8a85d146b0eebf007d5018a9ff406cb099614270f3428ec1db48"; +const REQUEST_TRACE_IDENTITY_SOURCE: &str = include_str!("request_trace_identity_v3.sql"); +const REQUEST_TRACE_IDENTITY_SOURCE_SHA256: &str = + "36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94"; +const BASELINE_RELATIONS: &[&str] = &[ + "workspaces", + "users", + "memberships", + "user_sessions", + "invitation_tokens", + "platform_api_keys", + "operations", + "operation_versions", + "published_operations", + "operation_samples", + "descriptors", + "agents", + "agent_versions", + "published_agents", + "agent_operation_bindings", + "secrets", + "secret_versions", + "auth_profiles", + "workspace_upstreams", + "yaml_import_jobs", + "import_jobs", + "approval_requests", + "invocation_logs", + "usage_rollups", +]; +const CONSOLIDATION_RELATIONS: &[&str] = &[ + "__crank_migrations", + "__crank_migration_legacy_audit", + "__crank_mcp_migrations", + "mcp_transport_sessions", + "__crank_ext_migrations", +]; +const REGISTERED_EXTENSION_MIGRATIONS: &[(&str, ExtensionMigration)] = &[]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MigrationDescriptor { + pub version: i64, + pub name: &'static str, + pub checksum: String, + pub source_digest: String, + pub phase: &'static str, + pub compatibility: &'static str, + pub owner: &'static str, + pub transactional: bool, + pub backfill: BackfillPolicy, + pub readable_schema_min: i64, + pub readable_schema_max: i64, + pub contract_evidence: Option<&'static str>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackfillPolicy { + None, + Bounded { + max_batch_rows: u32, + max_batch_ms: u32, + resumable: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BackfillBatch { + pub cursor: Option, + pub max_rows: u32, + pub max_ms: u32, +} + +impl BackfillBatch { + pub fn validate(&self, policy: BackfillPolicy) -> Result<(), MigrationError> { + match policy { + BackfillPolicy::Bounded { + max_batch_rows, + max_batch_ms, + resumable: true, + } if (1..=max_batch_rows).contains(&self.max_rows) + && (1..=max_batch_ms).contains(&self.max_ms) + && self + .cursor + .as_ref() + .is_none_or(|cursor| cursor.len() <= 256) => + { + Ok(()) + } + _ => Err(MigrationError::new( + "invalid_contract", + "contract.backfill", + None, + "contact_operator", + )), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MigrationPreflight { + Current { version: i64 }, + MigrationRequired { current: i64, target: i64 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MigrationApplyResult { + Applied { from: i64, to: i64 }, + AlreadyCurrent { version: i64 }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MigrationError { + code: &'static str, + stage: &'static str, + version: Option, + recovery: &'static str, +} + +impl MigrationError { + pub(super) fn new( + code: &'static str, + stage: &'static str, + version: Option, + recovery: &'static str, + ) -> Self { + Self { + code, + stage, + version, + recovery, + } + } + + pub(super) fn storage(stage: &'static str) -> Self { + Self::new("storage_unavailable", stage, None, "contact_operator") + } + + pub fn code(&self) -> &'static str { + self.code + } + + pub fn stage(&self) -> &'static str { + self.stage + } + + pub fn version(&self) -> Option { + self.version + } + + pub fn recovery(&self) -> &'static str { + self.recovery + } +} + +impl fmt::Display for MigrationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "migration_error code={} stage={} version={} recovery={}", + self.code, + self.stage, + self.version + .map_or_else(|| "none".to_owned(), |value| value.to_string()), + self.recovery + ) + } +} + +impl std::error::Error for MigrationError {} + +pub struct MigrationAuthority; + +impl MigrationAuthority { + pub fn registered_extension_migrations() -> &'static [(&'static str, ExtensionMigration)] { + REGISTERED_EXTENSION_MIGRATIONS + } + + pub fn sequence() -> Vec { + vec![ + MigrationDescriptor { + version: i64::from(BASELINE_VERSION), + name: "community-baseline-v1", + checksum: BASELINE_CHECKSUM.to_owned(), + source_digest: BASELINE_SOURCE_SHA256.to_owned(), + phase: "expand", + compatibility: "legacy-baseline", + owner: "crank-registry", + transactional: true, + backfill: BackfillPolicy::None, + readable_schema_min: 1, + readable_schema_max: 1, + contract_evidence: None, + }, + MigrationDescriptor { + version: 2, + name: "legacy-consolidation-v2", + checksum: CONSOLIDATION_SOURCE_SHA256.to_owned(), + source_digest: CONSOLIDATION_SOURCE_SHA256.to_owned(), + phase: "expand", + compatibility: "n-minus-one-readable", + owner: "crank-registry", + transactional: true, + backfill: BackfillPolicy::None, + readable_schema_min: 1, + readable_schema_max: 2, + contract_evidence: None, + }, + MigrationDescriptor { + version: 3, + name: "request-trace-identity-v3", + checksum: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(), + source_digest: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(), + phase: "expand", + compatibility: "n-minus-one-readable", + owner: "crank-registry", + transactional: true, + backfill: BackfillPolicy::None, + readable_schema_min: 2, + readable_schema_max: 3, + contract_evidence: None, + }, + ] + } + + pub fn validate_sequence() -> Result<(), MigrationError> { + validate_descriptors(&Self::sequence()) + } + + pub async fn preflight(pool: &PgPool) -> Result { + Self::validate_sequence()?; + let mut connection = pool + .acquire() + .await + .map_err(|_| MigrationError::storage("preflight.connect"))?; + inspect(&mut connection).await + } + + pub async fn require_current(pool: &PgPool) -> Result<(), MigrationError> { + match Self::preflight(pool).await? { + MigrationPreflight::Current { .. } => Ok(()), + MigrationPreflight::MigrationRequired { current: 0, .. } => Err(MigrationError::new( + "schema_missing", + "preflight.compatibility", + Some(0), + "run_controlled_migration", + )), + MigrationPreflight::MigrationRequired { current, .. } => Err(MigrationError::new( + "migration_required", + "preflight.compatibility", + Some(current), + "run_controlled_migration", + )), + } + } + + pub async fn apply(pool: &PgPool) -> Result { + Self::validate_sequence()?; + let mut transaction = pool + .begin() + .await + .map_err(|_| MigrationError::storage("apply.begin"))?; + query("set local lock_timeout = '30s'") + .execute(&mut *transaction) + .await + .map_err(|_| MigrationError::storage("apply.lock_policy"))?; + query("select pg_advisory_xact_lock($1)") + .bind(MIGRATION_LOCK_ID) + .execute(&mut *transaction) + .await + .map_err(|error| { + if error + .as_database_error() + .and_then(|database| database.code()) + .is_some_and(|code| matches!(code.as_ref(), "55P03" | "57014")) + { + MigrationError::new("lock_timeout", "apply.lock", None, "run_preflight") + } else { + MigrationError::storage("apply.lock") + } + })?; + + let before = inspect(&mut transaction).await?; + let from = match before { + MigrationPreflight::Current { version } => { + transaction + .commit() + .await + .map_err(|_| MigrationError::storage("apply.commit"))?; + return Ok(MigrationApplyResult::AlreadyCurrent { version }); + } + MigrationPreflight::MigrationRequired { current, .. } => current, + }; + + if from == 0 { + create_core_ledger(&mut transaction).await?; + apply_baseline(&mut transaction).await.map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.baseline", + Some(1), + "restore_known_good_backup", + ) + })?; + query( + "insert into __crank_core_migrations (version, description, checksum) + values (1, 'community baseline', $1)", + ) + .bind(BASELINE_CHECKSUM) + .execute(&mut *transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.baseline_ledger", + Some(1), + "restore_known_good_backup", + ) + })?; + } + + if from < 2 { + apply_consolidation(&mut transaction).await?; + } + if from < 3 { + apply_request_trace_identity(&mut transaction).await?; + } + + transaction + .commit() + .await + .map_err(|_| MigrationError::storage("apply.commit"))?; + Ok(MigrationApplyResult::Applied { + from, + to: CURRENT_VERSION, + }) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +fn baseline_source_digest() -> String { + let source = include_str!("baseline_v1.rs"); + let (_, baseline) = source + .split_once("// baseline-v1:start\n") + .expect("baseline start marker must exist"); + let (baseline, _) = baseline + .split_once("// baseline-v1:end") + .expect("baseline end marker must exist"); + sha256_hex(baseline.as_bytes()) +} + +fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> { + if descriptors.is_empty() || descriptors.len() > 1_024 { + return Err(MigrationError::new( + "invalid_contract", + "contract.sequence", + None, + "contact_operator", + )); + } + for (index, descriptor) in descriptors.iter().enumerate() { + let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX); + let valid_name = !descriptor.name.is_empty() + && descriptor.name.len() <= 128 + && descriptor + .name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !descriptors[..index] + .iter() + .any(|prior| prior.name == descriptor.name); + let valid_source_digest = descriptor.source_digest.len() == 64 + && descriptor + .source_digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()); + let valid_checksum = if descriptor.version == 1 { + descriptor.checksum == BASELINE_CHECKSUM + } else { + descriptor.checksum == descriptor.source_digest + }; + let valid_window = descriptor.readable_schema_min >= 1 + && descriptor.readable_schema_min <= descriptor.readable_schema_max + && descriptor.readable_schema_max <= descriptor.version; + let valid_phase = match descriptor.phase { + "expand" => descriptor.backfill == BackfillPolicy::None, + "migrate" => matches!( + descriptor.backfill, + BackfillPolicy::Bounded { + max_batch_rows: 1..=10_000, + max_batch_ms: 1..=60_000, + resumable: true, + } + ), + "contract" => { + descriptor.compatibility == "window-closed" + && descriptor.contract_evidence.is_some_and(|evidence| { + !evidence.is_empty() + && evidence.len() <= 256 + && !evidence.starts_with('/') + && !evidence.contains("..") + }) + && descriptor.backfill == BackfillPolicy::None + } + _ => false, + }; + let valid_compatibility = matches!( + descriptor.compatibility, + "legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed" + ); + let valid_owner = !descriptor.owner.is_empty() + && descriptor.owner.len() <= 128 + && descriptor + .owner + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if descriptor.version != expected_version + || !valid_name + || !valid_source_digest + || !valid_checksum + || !valid_phase + || !valid_compatibility + || !valid_owner + || !valid_window + || !descriptor.transactional + { + return Err(MigrationError::new( + "invalid_contract", + "contract.sequence", + Some(descriptor.version), + "contact_operator", + )); + } + } + if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default() + || descriptors + .iter() + .map(|descriptor| descriptor.version) + .ne(IMPLEMENTED_VERSIONS.iter().copied()) + || sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256 + || sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes()) + != REQUEST_TRACE_IDENTITY_SOURCE_SHA256 + { + return Err(MigrationError::new( + "invalid_contract", + "contract.implementation", + Some(CURRENT_VERSION), + "contact_operator", + )); + } + Ok(()) +} + +async fn inspect(connection: &mut PgConnection) -> Result { + let core_exists = relation_exists(connection, "__crank_core_migrations").await?; + let canonical_exists = relation_exists(connection, "__crank_migrations").await?; + + if !core_exists { + let mut owned_exists = canonical_exists; + for relation in OWNED_RELATIONS { + owned_exists |= relation_exists(connection, relation).await?; + } + if owned_exists { + return Err(MigrationError::new( + "partial_sequence", + "preflight.core", + None, + "restore_known_good_backup", + )); + } + inspect_optional_legacy(connection).await?; + return Ok(MigrationPreflight::MigrationRequired { + current: 0, + target: CURRENT_VERSION, + }); + } + + validate_core_ledger(connection).await?; + validate_required_relations(connection, BASELINE_RELATIONS, 1).await?; + inspect_optional_legacy(connection).await?; + + if !canonical_exists { + return Ok(MigrationPreflight::MigrationRequired { + current: 1, + target: CURRENT_VERSION, + }); + } + + let descriptors = MigrationAuthority::sequence(); + let rows = query("select version, name, checksum, phase, compatibility from __crank_migrations order by version limit 1025") + .fetch_all(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + if rows.is_empty() { + return Err(MigrationError::new( + "partial_sequence", + "preflight.canonical", + None, + "restore_known_good_backup", + )); + } + for (index, row) in rows.iter().enumerate() { + let version = row + .try_get::("version") + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + if version > CURRENT_VERSION { + return Err(MigrationError::new( + "future_version", + "preflight.canonical", + Some(version), + "install_matching_application", + )); + } + let Some(expected) = descriptors.get(index) else { + return Err(MigrationError::new( + "future_version", + "preflight.canonical", + Some(version), + "install_matching_application", + )); + }; + if version != expected.version { + return Err(MigrationError::new( + "partial_sequence", + "preflight.canonical", + Some(version), + "restore_known_good_backup", + )); + } + let checksum = row + .try_get::("checksum") + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + if checksum != expected.checksum { + return Err(MigrationError::new( + "checksum_mismatch", + "preflight.canonical", + Some(version), + "restore_known_good_backup", + )); + } + let name = row + .try_get::("name") + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + let phase = row + .try_get::("phase") + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + let compatibility = row + .try_get::("compatibility") + .map_err(|_| MigrationError::storage("preflight.canonical"))?; + if name != expected.name + || phase != expected.phase + || compatibility != expected.compatibility + { + return Err(MigrationError::new( + "checksum_mismatch", + "preflight.metadata", + Some(version), + "restore_known_good_backup", + )); + } + } + + let current = rows + .last() + .and_then(|row| row.try_get::("version").ok()) + .ok_or_else(|| MigrationError::storage("preflight.canonical"))?; + if rows.len() != usize::try_from(current).unwrap_or_default() { + return Err(MigrationError::new( + "partial_sequence", + "preflight.canonical", + Some(current), + "restore_known_good_backup", + )); + } + validate_schema_fingerprint(connection, current).await?; + if current < CURRENT_VERSION { + Ok(MigrationPreflight::MigrationRequired { + current, + target: CURRENT_VERSION, + }) + } else { + validate_required_relations(connection, CONSOLIDATION_RELATIONS, CURRENT_VERSION).await?; + validate_schema_fingerprint(connection, CURRENT_VERSION).await?; + validate_legacy_audit(connection).await?; + Ok(MigrationPreflight::Current { version: current }) + } +} + +async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> { + let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2") + .fetch_all(connection) + .await + .map_err(|_| MigrationError::storage("preflight.core"))?; + if rows.len() != 1 { + return Err(MigrationError::new( + "partial_sequence", + "preflight.core", + None, + "restore_known_good_backup", + )); + } + let version = rows[0] + .try_get::("version") + .map_err(|_| MigrationError::storage("preflight.core"))?; + let checksum = rows[0] + .try_get::("checksum") + .map_err(|_| MigrationError::storage("preflight.core"))?; + let description = rows[0] + .try_get::("description") + .map_err(|_| MigrationError::storage("preflight.core"))?; + if version != BASELINE_VERSION + || description != "community baseline" + || checksum != BASELINE_CHECKSUM + { + return Err(MigrationError::new( + "checksum_mismatch", + "preflight.core", + Some(i64::from(version)), + "restore_known_good_backup", + )); + } + Ok(()) +} + +async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> { + let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?; + let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?; + if mcp_ledger != mcp_sessions { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_mcp", + None, + "restore_known_good_backup", + )); + } + if mcp_ledger { + let rows = + query("select version, checksum from __crank_mcp_migrations order by version limit 2") + .fetch_all(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.legacy_mcp"))?; + if rows.len() != 1 + || rows[0].try_get::("version").ok() != Some(1) + || rows[0].try_get::("checksum").ok().as_deref() + != Some("mcp-transport-sessions-v1") + { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_mcp", + None, + "restore_known_good_backup", + )); + } + } + if relation_exists(connection, "__crank_ext_migrations").await? { + let count = query("select count(*)::bigint as count from __crank_ext_migrations") + .fetch_one(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))? + .try_get::("count") + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + if count > 1_000 { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_extension", + None, + "contact_operator", + )); + } + if count > 0 { + let checksum_column = query( + "select exists ( + select 1 from information_schema.columns + where table_schema = current_schema() + and table_name = '__crank_ext_migrations' + and column_name = 'checksum' + ) as present", + ) + .fetch_one(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))? + .try_get::("present") + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + if !checksum_column { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_extension", + None, + "contact_operator", + )); + } + let rows = query( + "select extension_name, version, checksum + from __crank_ext_migrations + order by extension_name, version + limit 1001", + ) + .fetch_all(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + for row in rows { + let name = row + .try_get::("extension_name") + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + let version = row + .try_get::("version") + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + let checksum = row + .try_get::, _>("checksum") + .map_err(|_| MigrationError::storage("preflight.legacy_extension"))?; + let registered = MigrationAuthority::registered_extension_migrations() + .iter() + .any(|(registered_name, descriptor)| { + *registered_name == name + && descriptor.version == u32::try_from(version).unwrap_or_default() + && checksum.as_deref() == Some(descriptor.checksum) + }); + if !registered { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_extension", + Some(i64::from(version)), + "contact_operator", + )); + } + } + } + } + Ok(()) +} + +async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), MigrationError> { + let rows = query( + "select source, source_version, source_checksum + from __crank_migration_legacy_audit + order by source, source_version + limit 1002", + ) + .fetch_all(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.legacy_audit"))?; + if rows.len() > 1001 { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_audit", + None, + "contact_operator", + )); + } + for row in rows { + let source = row + .try_get::("source") + .map_err(|_| MigrationError::storage("preflight.legacy_audit"))?; + let version = row + .try_get::("source_version") + .map_err(|_| MigrationError::storage("preflight.legacy_audit"))?; + let checksum = row + .try_get::("source_checksum") + .map_err(|_| MigrationError::storage("preflight.legacy_audit"))?; + let valid = (source == "core" && version == 1 && checksum == BASELINE_CHECKSUM) + || (source == "mcp-session" && version == 1 && checksum == "mcp-transport-sessions-v1") + || source.strip_prefix("extension:").is_some_and(|name| { + MigrationAuthority::registered_extension_migrations() + .iter() + .any(|(registered_name, descriptor)| { + *registered_name == name + && i64::from(descriptor.version) == version + && descriptor.checksum == checksum + }) + }); + if !valid { + return Err(MigrationError::new( + "legacy_conflict", + "preflight.legacy_audit", + Some(version), + "restore_known_good_backup", + )); + } + } + Ok(()) +} + +async fn create_core_ledger( + transaction: &mut Transaction<'_, sqlx::Postgres>, +) -> Result<(), MigrationError> { + query( + "create table __crank_core_migrations ( + version integer primary key, + description text not null, + checksum text not null, + applied_at timestamptz not null default now() + )", + ) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.core_ledger", + Some(1), + "restore_known_good_backup", + ) + })?; + Ok(()) +} + +async fn apply_consolidation( + transaction: &mut Transaction<'_, sqlx::Postgres>, +) -> Result<(), MigrationError> { + sqlx::raw_sql(CONSOLIDATION_SOURCE) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.consolidation", + Some(2), + "restore_known_good_backup", + ) + })?; + for (name, extension) in MigrationAuthority::registered_extension_migrations() { + query( + "insert into __crank_migration_legacy_audit + (source, source_version, source_checksum) + select 'extension:' || $1, $2, $3 + from __crank_ext_migrations + where extension_name = $1 and version = $2 and checksum = $3", + ) + .bind(name) + .bind(i64::from(extension.version)) + .bind(extension.checksum) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.extension_audit", + Some(2), + "restore_known_good_backup", + ) + })?; + } + let descriptor = &MigrationAuthority::sequence()[1]; + query( + "insert into __crank_migrations (version, name, checksum, phase, compatibility) + values ($1, $2, $3, $4, $5)", + ) + .bind(descriptor.version) + .bind(descriptor.name) + .bind(&descriptor.checksum) + .bind(descriptor.phase) + .bind(descriptor.compatibility) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.canonical_ledger", + Some(2), + "restore_known_good_backup", + ) + })?; + Ok(()) +} + +async fn apply_request_trace_identity( + transaction: &mut Transaction<'_, sqlx::Postgres>, +) -> Result<(), MigrationError> { + sqlx::raw_sql(REQUEST_TRACE_IDENTITY_SOURCE) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.request_trace_identity", + Some(3), + "restore_known_good_backup", + ) + })?; + let descriptor = &MigrationAuthority::sequence()[2]; + query( + "insert into __crank_migrations (version, name, checksum, phase, compatibility) + values ($1, $2, $3, $4, $5)", + ) + .bind(descriptor.version) + .bind(descriptor.name) + .bind(&descriptor.checksum) + .bind(descriptor.phase) + .bind(descriptor.compatibility) + .execute(&mut **transaction) + .await + .map_err(|_| { + MigrationError::new( + "apply_failed", + "apply.canonical_ledger", + Some(3), + "restore_known_good_backup", + ) + })?; + Ok(()) +} + +#[cfg(test)] +#[path = "authority_tests.rs"] +mod tests; diff --git a/crates/crank-registry/src/migrations/authority_tests.rs b/crates/crank-registry/src/migrations/authority_tests.rs new file mode 100644 index 0000000..ca9a298 --- /dev/null +++ b/crates/crank-registry/src/migrations/authority_tests.rs @@ -0,0 +1,115 @@ +use super::*; + +#[test] +fn sequence_is_deterministic_and_append_only() { + let first = MigrationAuthority::sequence(); + let second = MigrationAuthority::sequence(); + assert_eq!(first, second); + MigrationAuthority::validate_sequence().unwrap(); + assert_eq!( + first.iter().map(|item| item.version).collect::>(), + vec![1, 2, 3] + ); + assert_eq!(first[0].checksum, "crank-community-baseline-v1"); + assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256); + assert_eq!( + baseline_source_digest(), + BASELINE_SOURCE_SHA256, + "baseline v1 source changed; add a new migration instead" + ); + assert_eq!(first[1].checksum.len(), 64); + assert!( + first[1] + .checksum + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); +} + +#[test] +fn invalid_gap_checksum_phase_and_unbounded_backfill_are_rejected() { + let base = MigrationAuthority::sequence(); + for invalid in [ + { + let mut value = base.clone(); + value[1].version = 3; + value + }, + { + let mut value = base.clone(); + value[1].checksum = "0".repeat(64); + value + }, + { + let mut value = base.clone(); + value[1].phase = "unknown"; + value + }, + { + let mut value = base.clone(); + value[1].name = value[0].name; + value + }, + { + let mut value = base.clone(); + value[1].transactional = false; + value + }, + { + let mut value = base.clone(); + value[1].phase = "migrate"; + value[1].backfill = BackfillPolicy::Bounded { + max_batch_rows: 10_001, + max_batch_ms: 60_001, + resumable: false, + }; + value + }, + { + let mut value = base.clone(); + value[1].phase = "contract"; + value[1].compatibility = "window-closed"; + value[1].contract_evidence = None; + value + }, + ] { + assert_eq!( + validate_descriptors(&invalid).unwrap_err().code(), + "invalid_contract" + ); + } +} + +#[test] +fn backfill_batches_are_bounded_and_resumable() { + let policy = BackfillPolicy::Bounded { + max_batch_rows: 100, + max_batch_ms: 1_000, + resumable: true, + }; + BackfillBatch { + cursor: Some("next-100".to_owned()), + max_rows: 100, + max_ms: 1_000, + } + .validate(policy) + .unwrap(); + assert!( + BackfillBatch { + cursor: None, + max_rows: 101, + max_ms: 1_000, + } + .validate(policy) + .is_err() + ); +} + +#[test] +fn diagnostic_is_bounded_and_does_not_echo_storage_details() { + let error = MigrationError::storage("preflight.connect"); + let rendered = error.to_string(); + assert!(rendered.len() < 512); + assert!(!rendered.contains("postgres://")); + assert_eq!(error.code(), "storage_unavailable"); +} diff --git a/crates/crank-registry/src/migrations/baseline_v1.rs b/crates/crank-registry/src/migrations/baseline_v1.rs new file mode 100644 index 0000000..4ecf099 --- /dev/null +++ b/crates/crank-registry/src/migrations/baseline_v1.rs @@ -0,0 +1,673 @@ +use sqlx::{Postgres, Transaction, query}; + +pub(super) const BASELINE_VERSION: i32 = 1; +pub(super) const BASELINE_CHECKSUM: &str = "crank-community-baseline-v1"; + +// baseline-v1:start +pub(super) async fn apply_baseline( + transaction: &mut Transaction<'_, Postgres>, +) -> Result<(), sqlx::Error> { + query( + "create table if not exists workspaces ( + id text primary key, + slug text not null unique, + display_name text not null, + status text not null, + settings_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists users ( + id text primary key, + email text not null unique, + display_name text not null, + password_hash text null, + status text not null, + created_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query("alter table users add column if not exists password_hash text null") + .execute(&mut **transaction) + .await?; + + query( + "insert into users ( + id, + email, + display_name, + status, + created_at + ) values ( + 'user_default_owner', + 'owner@crank.local', + 'Workspace Owner', + 'active', + now() + ) + on conflict (id) do nothing", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists memberships ( + workspace_id text not null references workspaces(id) on delete cascade, + user_id text not null references users(id) on delete cascade, + role text not null, + created_at timestamptz not null, + primary key (workspace_id, user_id) + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists user_sessions ( + id text primary key, + user_id text not null references users(id) on delete cascade, + current_workspace_id text null references workspaces(id) on delete set null, + secret_hash text not null, + status text not null, + expires_at timestamptz not null, + last_seen_at timestamptz null, + created_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "alter table user_sessions + add column if not exists current_workspace_id text null references workspaces(id) on delete set null", + ) + .execute(&mut **transaction) + .await?; + + query( + "insert into workspaces ( + id, + slug, + display_name, + status, + settings_json, + created_at, + updated_at + ) values ( + 'ws_default', + 'default', + 'Default Workspace', + 'active', + '{}'::jsonb, + now(), + now() + ) + on conflict (id) do nothing", + ) + .execute(&mut **transaction) + .await?; + + query( + "insert into memberships ( + workspace_id, + user_id, + role, + created_at + ) values ( + 'ws_default', + 'user_default_owner', + 'owner', + now() + ) + on conflict (workspace_id, user_id) do nothing", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists invitation_tokens ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + email text not null, + role text not null, + status text not null, + token_hash text not null, + expires_at timestamptz not null, + created_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists platform_api_keys ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + agent_id text null, + name text not null, + prefix text not null, + secret_hash text not null, + key_kind text not null default 'mcp_client', + scopes_json jsonb not null, + status text not null, + created_at timestamptz not null, + last_used_at timestamptz null, + revoked_at timestamptz null, + expires_at timestamptz null, + allowed_origins_json jsonb not null default '[]'::jsonb + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)", + ) + .execute(&mut **transaction) + .await?; + query("alter table platform_api_keys add column if not exists agent_id text null") + .execute(&mut **transaction) + .await?; + query( + "alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'", + ) + .execute(&mut **transaction) + .await?; + query("alter table platform_api_keys add column if not exists expires_at timestamptz null") + .execute(&mut **transaction) + .await?; + query( + "alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb", + ) + .execute(&mut **transaction) + .await?; + + query( + "insert into workspaces ( + id, + slug, + display_name, + status, + settings_json, + created_at, + updated_at + ) values ( + 'ws_default', + 'default', + 'Default Workspace', + 'active', + '{}'::jsonb, + now(), + now() + ) + on conflict (id) do nothing", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists operations ( + id text primary key, + workspace_id text null references workspaces(id) on delete cascade, + name text not null, + display_name text not null, + category text not null default 'general', + protocol text not null, + security_level text not null default 'standard', + status text not null, + current_draft_version integer not null default 1, + latest_published_version integer null, + created_at timestamptz not null, + updated_at timestamptz not null, + published_at timestamptz null + )", + ) + .execute(&mut **transaction) + .await?; + + query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade") + .execute(&mut **transaction) + .await?; + query( + "alter table operations add column if not exists category text not null default 'general'", + ) + .execute(&mut **transaction) + .await?; + query( + "alter table operations add column if not exists security_level text not null default 'standard'", + ) + .execute(&mut **transaction) + .await?; + query("update operations set workspace_id = 'ws_default' where workspace_id is null") + .execute(&mut **transaction) + .await?; + query("alter table operations alter column workspace_id set not null") + .execute(&mut **transaction) + .await?; + query("alter table operations drop constraint if exists operations_name_key") + .execute(&mut **transaction) + .await?; + query( + "create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists operation_versions ( + operation_id text not null references operations(id) on delete cascade, + version integer not null, + status text not null, + target_json jsonb not null, + input_schema_json jsonb not null, + output_schema_json jsonb not null, + input_mapping_json jsonb not null, + output_mapping_json jsonb not null, + execution_config_json jsonb not null, + tool_description_json jsonb not null, + samples_json jsonb null, + generated_draft_json jsonb null, + config_export_json jsonb null, + wizard_state_json jsonb null, + change_note text null, + created_at timestamptz not null, + created_by text null, + primary key (operation_id, version) + )", + ) + .execute(&mut **transaction) + .await?; + + query("alter table operation_versions add column if not exists wizard_state_json jsonb null") + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists published_operations ( + operation_id text primary key references operations(id) on delete cascade, + version integer not null, + published_at timestamptz not null, + published_by text null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists operation_samples ( + id text primary key, + operation_id text not null references operations(id) on delete cascade, + version integer not null, + sample_kind text not null, + storage_ref text not null, + content_type text not null, + file_name text null, + created_at timestamptz not null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists descriptors ( + id text primary key, + operation_id text null references operations(id) on delete cascade, + version integer null, + descriptor_kind text not null, + storage_ref text not null, + source_name text null, + package_index_json jsonb null, + created_at timestamptz not null, + foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists auth_profiles ( + id text primary key, + workspace_id text null references workspaces(id) on delete cascade, + name text not null, + kind text not null, + config_json jsonb not null, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade") + .execute(&mut **transaction) + .await?; + query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null") + .execute(&mut **transaction) + .await?; + query("alter table auth_profiles alter column workspace_id set not null") + .execute(&mut **transaction) + .await?; + query("alter table auth_profiles drop constraint if exists auth_profiles_name_key") + .execute(&mut **transaction) + .await?; + query( + "create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists workspace_upstreams ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + name text not null, + base_url text not null, + static_headers_json jsonb not null default '{}'::jsonb, + auth_profile_id text null references auth_profiles(id) on delete set null, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + query( + "create unique index if not exists workspace_upstreams_workspace_name_idx on workspace_upstreams(workspace_id, name)", + ) + .execute(&mut **transaction) + .await?; + query( + "create unique index if not exists workspace_upstreams_workspace_base_auth_idx on workspace_upstreams(workspace_id, base_url, coalesce(auth_profile_id, ''))", + ) + .execute(&mut **transaction) + .await?; + query( + "insert into workspace_upstreams ( + id, + workspace_id, + name, + base_url, + static_headers_json, + auth_profile_id, + created_at, + updated_at + ) + select + 'upstream_frankfurter_' || w.id, + w.id, + 'Frankfurter', + 'https://api.frankfurter.dev', + '{}'::jsonb, + null, + now(), + now() + from workspaces w + where not exists ( + select 1 + from workspace_upstreams wu + where wu.workspace_id = w.id + and wu.name = 'Frankfurter' + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists secrets ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + name text not null, + kind text not null, + status text not null, + current_version integer not null, + last_used_at timestamptz null, + created_at timestamptz not null, + updated_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists secret_versions ( + secret_id text not null references secrets(id) on delete cascade, + version integer not null, + ciphertext text not null, + key_version text not null, + created_at timestamptz not null, + created_by text null references users(id) on delete set null, + primary key (secret_id, version) + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists yaml_import_jobs ( + id text primary key, + source_sample_id text null references operation_samples(id) on delete set null, + status text not null, + format_version text not null, + mode text not null, + result_operation_id text null references operations(id) on delete set null, + result_version integer null, + error_text text null, + created_at timestamptz not null, + finished_at timestamptz null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists import_jobs ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + kind text not null, + source_format text not null, + source_version text null, + status text not null, + preview_payload jsonb not null, + created_operation_ids jsonb not null default '[]'::jsonb, + error_text text null, + created_at timestamptz not null, + expires_at timestamptz not null, + finished_at timestamptz null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists agents ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + slug text not null, + display_name text not null, + description text not null, + status text not null, + current_draft_version integer not null default 1, + latest_published_version integer null, + created_at timestamptz not null, + updated_at timestamptz not null, + published_at timestamptz null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists agent_versions ( + agent_id text not null references agents(id) on delete cascade, + version integer not null, + status text not null, + instructions_json jsonb not null, + tool_selection_policy_json jsonb not null, + created_at timestamptz not null, + primary key (agent_id, version) + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists agent_operation_bindings ( + agent_id text not null references agents(id) on delete cascade, + agent_version integer not null, + operation_id text not null references operations(id) on delete cascade, + operation_version integer not null, + tool_name text not null, + tool_title text not null, + tool_description_override text null, + enabled boolean not null default true, + foreign key (agent_id, agent_version) references agent_versions(agent_id, version) on delete cascade, + foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create unique index if not exists agent_bindings_tool_name_idx on agent_operation_bindings(agent_id, agent_version, tool_name)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists published_agents ( + agent_id text primary key references agents(id) on delete cascade, + version integer not null, + published_at timestamptz not null, + published_by text null, + foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists approval_requests ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + agent_id text not null references agents(id) on delete cascade, + operation_id text not null references operations(id) on delete cascade, + operation_version integer not null, + status text not null, + risk_level text not null, + request_payload_json jsonb not null, + response_payload_json jsonb null, + created_at timestamptz not null, + expires_at timestamptz not null, + decided_at timestamptz null, + decided_by_key_id text null references platform_api_keys(id) on delete set null, + decision_note text null + )", + ) + .execute(&mut **transaction) + .await?; + + query("alter table approval_requests add column if not exists execution_started_at timestamptz null") + .execute(&mut **transaction) + .await?; + query("alter table approval_requests add column if not exists execution_attempts integer not null default 0") + .execute(&mut **transaction) + .await?; + query("alter table approval_requests add column if not exists request_fingerprint text null") + .execute(&mut **transaction) + .await?; + query( + "create unique index if not exists approval_requests_pending_fingerprint_idx + on approval_requests(agent_id, operation_id, operation_version, request_fingerprint) + where status = 'pending' and request_fingerprint is not null", + ) + .execute(&mut **transaction) + .await?; + query( + "create index if not exists approval_requests_agent_status_idx + on approval_requests(workspace_id, agent_id, status, expires_at)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists invocation_logs ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + agent_id text null references agents(id) on delete set null, + operation_id text not null references operations(id) on delete cascade, + source text not null, + level text not null, + status text not null, + tool_name text not null, + message text not null, + request_id text null, + status_code integer null, + duration_ms bigint not null, + error_kind text null, + request_preview_json jsonb not null, + response_preview_json jsonb not null, + created_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + query( + "create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create index if not exists invocation_logs_workspace_operation_created_idx on invocation_logs(workspace_id, operation_id, created_at desc)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create index if not exists invocation_logs_workspace_agent_created_idx on invocation_logs(workspace_id, agent_id, created_at desc)", + ) + .execute(&mut **transaction) + .await?; + + query( + "create table if not exists usage_rollups ( + workspace_id text not null references workspaces(id) on delete cascade, + agent_id text null references agents(id) on delete cascade, + operation_id text null references operations(id) on delete cascade, + period text not null, + calls_total bigint not null, + calls_ok bigint not null, + calls_error bigint not null, + p50_ms bigint not null, + p95_ms bigint not null, + p99_ms bigint not null, + updated_at timestamptz not null + )", + ) + .execute(&mut **transaction) + .await?; + + Ok(()) +} +// baseline-v1:end diff --git a/crates/crank-registry/src/migrations/consolidation_v2.sql b/crates/crank-registry/src/migrations/consolidation_v2.sql new file mode 100644 index 0000000..fd55405 --- /dev/null +++ b/crates/crank-registry/src/migrations/consolidation_v2.sql @@ -0,0 +1,84 @@ +create temporary table __crank_v2_context ( + had_mcp_ledger boolean not null +) on commit drop; + +insert into __crank_v2_context (had_mcp_ledger) +values (to_regclass(format('%I.%I', current_schema(), '__crank_mcp_migrations')) is not null); + +create table __crank_migrations ( + version bigint primary key, + name text not null unique, + checksum text not null, + phase text not null, + compatibility text not null, + applied_at timestamptz not null default now() +); + +create table __crank_migration_legacy_audit ( + source text not null, + source_version bigint not null, + source_checksum text not null, + imported_at timestamptz not null default now(), + primary key (source, source_version) +); + +insert into __crank_migrations (version, name, checksum, phase, compatibility) +values ( + 1, + 'community-baseline-v1', + 'crank-community-baseline-v1', + 'expand', + 'legacy-baseline' +); + +insert into __crank_migration_legacy_audit (source, source_version, source_checksum) +values ('core', 1, 'crank-community-baseline-v1'); + +create table if not exists __crank_mcp_migrations ( + version integer primary key, + checksum text not null, + applied_at timestamptz not null default now() +); + +create table if not exists mcp_transport_sessions ( + id text primary key, + protocol_version text not null, + initialized boolean not null default false, + supports_elicitation boolean not null default false, + workspace_slug text not null, + agent_slug text not null, + created_at timestamptz not null, + updated_at timestamptz not null, + expires_at timestamptz null +); + +alter table mcp_transport_sessions + add column if not exists supports_elicitation boolean not null default false; +alter table mcp_transport_sessions + add column if not exists expires_at timestamptz null; + +create index if not exists mcp_transport_sessions_workspace_agent_idx + on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc); +create index if not exists mcp_transport_sessions_expires_at_idx + on mcp_transport_sessions(expires_at) + where expires_at is not null; + +insert into __crank_mcp_migrations (version, checksum) +values (1, 'mcp-transport-sessions-v1') +on conflict (version) do nothing; + +insert into __crank_migration_legacy_audit (source, source_version, source_checksum) +select 'mcp-session', 1, 'mcp-transport-sessions-v1' +from __crank_v2_context +where had_mcp_ledger; + +create table if not exists __crank_ext_migrations ( + extension_name text not null, + version integer not null, + checksum text null, + applied_at timestamptz not null default now(), + primary key (extension_name, version) +); + +alter table __crank_ext_migrations + add column if not exists checksum text null; diff --git a/crates/crank-registry/src/migrations/request_trace_identity_v3.sql b/crates/crank-registry/src/migrations/request_trace_identity_v3.sql new file mode 100644 index 0000000..1f4f492 --- /dev/null +++ b/crates/crank-registry/src/migrations/request_trace_identity_v3.sql @@ -0,0 +1,20 @@ +alter table invocation_logs + add column trace_id text; + +alter table invocation_logs + add constraint invocation_logs_trace_id_format_check + check ( + trace_id is null + or ( + trace_id ~ '^[0-9a-f]{32}$' + and trace_id <> '00000000000000000000000000000000' + ) + ) not valid; + +create index invocation_logs_workspace_request_id_idx + on invocation_logs(workspace_id, request_id) + where request_id is not null and octet_length(request_id) <= 128; + +create index invocation_logs_workspace_trace_id_idx + on invocation_logs(workspace_id, trace_id) + where trace_id is not null; diff --git a/crates/crank-registry/src/migrations/schema_guard.rs b/crates/crank-registry/src/migrations/schema_guard.rs new file mode 100644 index 0000000..3177ee3 --- /dev/null +++ b/crates/crank-registry/src/migrations/schema_guard.rs @@ -0,0 +1,451 @@ +use sqlx::{PgConnection, Row, query}; + +use super::authority::MigrationError; + +pub(super) const OWNED_RELATIONS: &[&str] = &[ + "__crank_core_migrations", + "__crank_migrations", + "__crank_migration_legacy_audit", + "__crank_mcp_migrations", + "__crank_ext_migrations", + "mcp_transport_sessions", + "workspaces", + "users", + "memberships", + "user_sessions", + "invitation_tokens", + "platform_api_keys", + "operations", + "operation_versions", + "published_operations", + "operation_samples", + "descriptors", + "agents", + "agent_versions", + "published_agents", + "agent_operation_bindings", + "secrets", + "secret_versions", + "auth_profiles", + "workspace_upstreams", + "yaml_import_jobs", + "import_jobs", + "approval_requests", + "invocation_logs", + "usage_rollups", +]; + +const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[ + ( + "__crank_core_migrations", + &["version", "description", "checksum", "applied_at"], + ), + ( + "__crank_migrations", + &[ + "version", + "name", + "checksum", + "phase", + "compatibility", + "applied_at", + ], + ), + ( + "__crank_migration_legacy_audit", + &["source", "source_version", "source_checksum", "imported_at"], + ), + ( + "__crank_mcp_migrations", + &["version", "checksum", "applied_at"], + ), + ( + "__crank_ext_migrations", + &["extension_name", "version", "checksum", "applied_at"], + ), + ( + "mcp_transport_sessions", + &[ + "id", + "protocol_version", + "initialized", + "supports_elicitation", + "workspace_slug", + "agent_slug", + "created_at", + "updated_at", + "expires_at", + ], + ), +]; + +const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[ + ("__crank_core_migrations", "version", "integer", false), + ("__crank_core_migrations", "description", "text", false), + ("__crank_core_migrations", "checksum", "text", false), + ( + "__crank_core_migrations", + "applied_at", + "timestamp with time zone", + false, + ), + ("__crank_migrations", "version", "bigint", false), + ("__crank_migrations", "name", "text", false), + ("__crank_migrations", "checksum", "text", false), + ("__crank_migrations", "phase", "text", false), + ("__crank_migrations", "compatibility", "text", false), + ( + "__crank_migrations", + "applied_at", + "timestamp with time zone", + false, + ), + ("__crank_migration_legacy_audit", "source", "text", false), + ( + "__crank_migration_legacy_audit", + "source_version", + "bigint", + false, + ), + ( + "__crank_migration_legacy_audit", + "source_checksum", + "text", + false, + ), + ( + "__crank_migration_legacy_audit", + "imported_at", + "timestamp with time zone", + false, + ), + ("__crank_mcp_migrations", "version", "integer", false), + ("__crank_mcp_migrations", "checksum", "text", false), + ( + "__crank_mcp_migrations", + "applied_at", + "timestamp with time zone", + false, + ), + ("__crank_ext_migrations", "extension_name", "text", false), + ("__crank_ext_migrations", "version", "integer", false), + ("__crank_ext_migrations", "checksum", "text", true), + ( + "__crank_ext_migrations", + "applied_at", + "timestamp with time zone", + false, + ), + ("mcp_transport_sessions", "id", "text", false), + ("mcp_transport_sessions", "protocol_version", "text", false), + ("mcp_transport_sessions", "initialized", "boolean", false), + ( + "mcp_transport_sessions", + "supports_elicitation", + "boolean", + false, + ), + ("mcp_transport_sessions", "workspace_slug", "text", false), + ("mcp_transport_sessions", "agent_slug", "text", false), + ( + "mcp_transport_sessions", + "created_at", + "timestamp with time zone", + false, + ), + ( + "mcp_transport_sessions", + "updated_at", + "timestamp with time zone", + false, + ), + ( + "mcp_transport_sessions", + "expires_at", + "timestamp with time zone", + true, + ), +]; + +pub(super) async fn relation_exists( + connection: &mut PgConnection, + relation: &str, +) -> Result { + query("select to_regclass(format('%I.%I', current_schema(), $1))::text is not null as present") + .bind(relation) + .fetch_one(connection) + .await + .map_err(|_| MigrationError::storage("preflight.inventory"))? + .try_get::("present") + .map_err(|_| MigrationError::storage("preflight.inventory")) +} + +pub(super) async fn validate_required_relations( + connection: &mut PgConnection, + relations: &[&str], + version: i64, +) -> Result<(), MigrationError> { + for relation in relations { + if !relation_exists(connection, relation).await? { + return Err(schema_error(version)); + } + let kind = query( + "select c.relkind::text as kind + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = current_schema() and c.relname = $1", + ) + .bind(relation) + .fetch_one(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))? + .try_get::("kind") + .map_err(|_| MigrationError::storage("preflight.schema"))?; + if !matches!(kind.as_str(), "r" | "p") { + return Err(schema_error(version)); + } + } + Ok(()) +} + +pub(super) async fn validate_schema_fingerprint( + connection: &mut PgConnection, + current_version: i64, +) -> Result<(), MigrationError> { + for (table, required) in REQUIRED_COLUMNS { + if !relation_exists(connection, table).await? { + continue; + } + let rows = query( + "select column_name from information_schema.columns + where table_schema = current_schema() and table_name = $1 + order by ordinal_position limit 257", + ) + .bind(table) + .fetch_all(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let actual = rows + .iter() + .filter_map(|row| row.try_get::("column_name").ok()) + .collect::>(); + if actual.len() != required.len() + || required + .iter() + .any(|column| !actual.iter().any(|actual| actual == column)) + { + return Err(schema_error(current_version)); + } + } + for (table, column, data_type, nullable) in REQUIRED_COLUMN_TYPES { + if !relation_exists(connection, table).await? { + continue; + } + let row = query( + "select data_type, is_nullable from information_schema.columns + where table_schema = current_schema() and table_name = $1 and column_name = $2", + ) + .bind(table) + .bind(column) + .fetch_optional(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let valid = row.is_some_and(|row| { + row.try_get::("data_type").ok().as_deref() == Some(*data_type) + && row.try_get::("is_nullable").ok().as_deref() + == Some(if *nullable { "YES" } else { "NO" }) + }); + if !valid { + return Err(schema_error(current_version)); + } + } + if current_version < 3 { + let trace_column_present = query( + "select exists ( + select 1 from information_schema.columns + where table_schema = current_schema() + and table_name = 'invocation_logs' + and column_name = 'trace_id' + ) as present", + ) + .fetch_one(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))? + .try_get::("present") + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let trace_constraint_present = named_constraint_exists( + connection, + "invocation_logs", + "invocation_logs_trace_id_format_check", + ) + .await?; + let v3_index_present = + relation_exists(connection, "invocation_logs_workspace_request_id_idx").await? + || relation_exists(connection, "invocation_logs_workspace_trace_id_idx").await?; + if trace_column_present || trace_constraint_present || v3_index_present { + return Err(schema_error(current_version)); + } + } else { + let trace_id = query( + "select data_type, is_nullable from information_schema.columns + where table_schema = current_schema() + and table_name = 'invocation_logs' + and column_name = 'trace_id'", + ) + .fetch_optional(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let valid = trace_id.is_some_and(|row| { + row.try_get::("data_type").ok().as_deref() == Some("text") + && row.try_get::("is_nullable").ok().as_deref() == Some("YES") + }); + if !valid { + return Err(schema_error(current_version)); + } + let constraint = query( + "select pg_get_expr(c.conbin, c.conrelid) as expression, c.convalidated + from pg_catalog.pg_constraint c + join pg_catalog.pg_class t on t.oid = c.conrelid + join pg_catalog.pg_namespace n on n.oid = t.relnamespace + where n.nspname = current_schema() + and t.relname = 'invocation_logs' + and c.conname = 'invocation_logs_trace_id_format_check' + and c.contype = 'c'", + ) + .fetch_optional(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let constraint_valid = constraint.is_some_and(|row| { + row.try_get::("expression") + .ok() + .is_some_and(|value| { + normalize_definition(&value) + == "trace_idisnullortrace_id~'^[0-9a-f]{32}$'::textandtrace_id<>'00000000000000000000000000000000'::text" + }) + && row.try_get::("convalidated").ok() == Some(false) + }); + if !constraint_valid { + return Err(schema_error(current_version)); + } + } + let required_indexes = [ + "mcp_transport_sessions_workspace_agent_idx", + "mcp_transport_sessions_expires_at_idx", + ]; + for index in required_indexes { + let present = query( + "select exists (select 1 from pg_catalog.pg_indexes + where schemaname = current_schema() and indexname = $1) as present", + ) + .bind(index) + .fetch_one(&mut *connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))? + .try_get::("present") + .map_err(|_| MigrationError::storage("preflight.schema"))?; + if !present { + return Err(schema_error(current_version)); + } + } + if current_version >= 3 { + validate_index( + connection, + "invocation_logs_workspace_request_id_idx", + "request_id", + "request_idisnotnullandoctet_lengthrequest_id<=128", + ) + .await?; + validate_index( + connection, + "invocation_logs_workspace_trace_id_idx", + "trace_id", + "trace_idisnotnull", + ) + .await?; + } + Ok(()) +} + +async fn named_constraint_exists( + connection: &mut PgConnection, + table: &str, + constraint: &str, +) -> Result { + query( + "select exists ( + select 1 from pg_catalog.pg_constraint c + join pg_catalog.pg_class t on t.oid = c.conrelid + join pg_catalog.pg_namespace n on n.oid = t.relnamespace + where n.nspname = current_schema() + and t.relname = $1 + and c.conname = $2 + ) as present", + ) + .bind(table) + .bind(constraint) + .fetch_one(connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))? + .try_get::("present") + .map_err(|_| MigrationError::storage("preflight.schema")) +} + +async fn validate_index( + connection: &mut PgConnection, + index: &str, + second_column: &str, + expected_predicate: &str, +) -> Result<(), MigrationError> { + let row = query( + "select + t.relname as table_name, + am.amname as access_method, + i.indisvalid, + i.indisready, + i.indisunique, + pg_get_indexdef(i.indexrelid, 1, true) as first_column, + pg_get_indexdef(i.indexrelid, 2, true) as second_column, + pg_get_expr(i.indpred, i.indrelid) as predicate + from pg_catalog.pg_index i + join pg_catalog.pg_class idx on idx.oid = i.indexrelid + join pg_catalog.pg_class t on t.oid = i.indrelid + join pg_catalog.pg_namespace n on n.oid = t.relnamespace + join pg_catalog.pg_am am on am.oid = idx.relam + where n.nspname = current_schema() and idx.relname = $1", + ) + .bind(index) + .fetch_optional(connection) + .await + .map_err(|_| MigrationError::storage("preflight.schema"))?; + let valid = row.is_some_and(|row| { + row.try_get::("table_name").ok().as_deref() == Some("invocation_logs") + && row.try_get::("access_method").ok().as_deref() == Some("btree") + && row.try_get::("indisvalid").ok() == Some(true) + && row.try_get::("indisready").ok() == Some(true) + && row.try_get::("indisunique").ok() == Some(false) + && row.try_get::("first_column").ok().as_deref() == Some("workspace_id") + && row.try_get::("second_column").ok().as_deref() == Some(second_column) + && row + .try_get::("predicate") + .ok() + .is_some_and(|value| normalize_definition(&value) == expected_predicate) + }); + if valid { Ok(()) } else { Err(schema_error(3)) } +} + +fn normalize_definition(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '(' | ')')) + .flat_map(char::to_lowercase) + .collect() +} + +fn schema_error(version: i64) -> MigrationError { + MigrationError::new( + "partial_sequence", + "preflight.schema", + Some(version), + "restore_known_good_backup", + ) +} diff --git a/crates/crank-registry/src/postgres/connection.rs b/crates/crank-registry/src/postgres/connection.rs index 8a1ae7e..ecb9048 100644 --- a/crates/crank-registry/src/postgres/connection.rs +++ b/crates/crank-registry/src/postgres/connection.rs @@ -5,7 +5,7 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, }; -use crate::{error::RegistryError, migrations}; +use crate::{MigrationAuthority, error::RegistryError}; use super::{PostgresPoolConfig, PostgresRegistry}; @@ -33,7 +33,7 @@ impl PostgresRegistry { .max_lifetime(Duration::from_millis(pool_config.max_lifetime_ms)) .connect_with(connect_options) .await?; - migrations::apply_postgres(&pool).await?; + MigrationAuthority::require_current(&pool).await?; Ok(Self { pool }) } @@ -46,11 +46,6 @@ impl PostgresRegistry { Ok(()) } - pub async fn migrate(&self) -> Result<(), RegistryError> { - migrations::apply_postgres(&self.pool).await?; - Ok(()) - } - async fn connect_in_schema( database_url: &str, schema: Option<&str>, @@ -69,7 +64,7 @@ impl PostgresRegistry { } let registry = Self { pool }; - registry.migrate().await?; + MigrationAuthority::require_current(®istry.pool).await?; Ok(registry) } } diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index ce279be..a96d9b5 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -331,6 +331,7 @@ fn map_invocation_log_record(row: &PgRow) -> Result, _>("status_code")? { Some(value) => { Some( diff --git a/crates/crank-registry/src/postgres/observability.rs b/crates/crank-registry/src/postgres/observability.rs index 4da565e..3dedb5d 100644 --- a/crates/crank-registry/src/postgres/observability.rs +++ b/crates/crank-registry/src/postgres/observability.rs @@ -43,6 +43,26 @@ impl PostgresRegistry { &self, request: CreateInvocationLogRequest<'_>, ) -> Result<(), RegistryError> { + let request_id = + request + .log + .request_id + .as_deref() + .ok_or(RegistryError::InvalidCorrelationIdentity { + field: "request_id", + })?; + crank_core::RequestId::parse(request_id).map_err(|_| { + RegistryError::InvalidCorrelationIdentity { + field: "request_id", + } + })?; + let trace_id = request + .log + .trace_id + .as_deref() + .ok_or(RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?; + crank_core::TraceId::parse(trace_id) + .map_err(|_| RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?; let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview); let response_preview = crank_core::sanitize_invocation_preview(&request.log.response_preview); @@ -58,6 +78,7 @@ impl PostgresRegistry { tool_name, message, request_id, + trace_id, status_code, duration_ms, error_kind, @@ -65,7 +86,7 @@ impl PostgresRegistry { response_preview_json, created_at ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::timestamptz )", ) .bind(request.log.id.as_str()) @@ -78,6 +99,7 @@ impl PostgresRegistry { .bind(&request.log.tool_name) .bind(&request.log.message) .bind(&request.log.request_id) + .bind(&request.log.trace_id) .bind(request.log.status_code.map(i32::from)) .bind(i64::try_from(request.log.duration_ms).map_err(|_| { RegistryError::InvalidNumericValue { @@ -111,6 +133,7 @@ impl PostgresRegistry { l.tool_name, l.message, l.request_id, + l.trace_id, l.status_code, l.duration_ms, l.error_kind, @@ -183,6 +206,7 @@ impl PostgresRegistry { l.tool_name, l.message, l.request_id, + l.trace_id, l.status_code, l.duration_ms, l.error_kind, diff --git a/crates/crank-registry/src/postgres/pool_config.rs b/crates/crank-registry/src/postgres/pool_config.rs index d32f98e..0454380 100644 --- a/crates/crank-registry/src/postgres/pool_config.rs +++ b/crates/crank-registry/src/postgres/pool_config.rs @@ -1,5 +1,3 @@ -use std::{collections::BTreeMap, env}; - use thiserror::Error; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -13,11 +11,9 @@ pub struct PostgresPoolConfig { #[derive(Debug, Error, PartialEq, Eq)] pub enum PostgresPoolConfigError { - #[error("invalid postgres pool setting {name}={value}")] - InvalidValue { name: &'static str, value: String }, - #[error("POSTGRES_MAX_CONNECTIONS must be greater than zero")] - ZeroMaxConnections, - #[error("POSTGRES_MIN_CONNECTIONS must not exceed POSTGRES_MAX_CONNECTIONS")] + #[error("postgres pool setting is outside its allowed bounds: {field}")] + OutOfRange { field: &'static str }, + #[error("minimum connections must not exceed maximum connections")] MinConnectionsExceedMax, } @@ -34,173 +30,60 @@ impl Default for PostgresPoolConfig { } impl PostgresPoolConfig { - pub fn from_env() -> Result { - Self::from_vars(env::vars()) - } - - fn from_vars(vars: I) -> Result - where - I: IntoIterator, - K: AsRef, - V: AsRef, - { - let vars = vars - .into_iter() - .map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned())) - .collect::>(); - let defaults = Self::default(); - let config = Self { - max_connections: parse_u32_setting( - &vars, - "POSTGRES_MAX_CONNECTIONS", - defaults.max_connections, - )?, - min_connections: parse_u32_setting( - &vars, - "POSTGRES_MIN_CONNECTIONS", - defaults.min_connections, - )?, - acquire_timeout_ms: parse_u64_setting( - &vars, - "POSTGRES_ACQUIRE_TIMEOUT_MS", - defaults.acquire_timeout_ms, - )?, - idle_timeout_ms: parse_u64_setting( - &vars, - "POSTGRES_IDLE_TIMEOUT_MS", - defaults.idle_timeout_ms, - )?, - max_lifetime_ms: parse_u64_setting( - &vars, - "POSTGRES_MAX_LIFETIME_MS", - defaults.max_lifetime_ms, - )?, - }; - - config.validate()?; - Ok(config) - } - - fn validate(self) -> Result { - if self.max_connections == 0 { - return Err(PostgresPoolConfigError::ZeroMaxConnections); + pub fn try_new( + max_connections: u32, + min_connections: u32, + acquire_timeout_ms: u64, + idle_timeout_ms: u64, + max_lifetime_ms: u64, + ) -> Result { + for (field, value, minimum, maximum) in [ + ("max_connections", u64::from(max_connections), 1, 1024), + ("min_connections", u64::from(min_connections), 0, 1024), + ("acquire_timeout_ms", acquire_timeout_ms, 1, 300_000), + ("idle_timeout_ms", idle_timeout_ms, 1_000, 86_400_000), + ("max_lifetime_ms", max_lifetime_ms, 1_000, 86_400_000), + ] { + if !(minimum..=maximum).contains(&value) { + return Err(PostgresPoolConfigError::OutOfRange { field }); + } } - if self.min_connections > self.max_connections { + if min_connections > max_connections { return Err(PostgresPoolConfigError::MinConnectionsExceedMax); } - - Ok(self) + Ok(Self { + max_connections, + min_connections, + acquire_timeout_ms, + idle_timeout_ms, + max_lifetime_ms, + }) } } -fn parse_u32_setting( - vars: &BTreeMap, - name: &'static str, - default: u32, -) -> Result { - vars.get(name) - .map(|value| { - value - .parse::() - .map_err(|_| PostgresPoolConfigError::InvalidValue { - name, - value: value.clone(), - }) - }) - .transpose() - .map(|value| value.unwrap_or(default)) -} - -fn parse_u64_setting( - vars: &BTreeMap, - name: &'static str, - default: u64, -) -> Result { - vars.get(name) - .map(|value| { - value - .parse::() - .map_err(|_| PostgresPoolConfigError::InvalidValue { - name, - value: value.clone(), - }) - }) - .transpose() - .map(|value| value.unwrap_or(default)) -} - #[cfg(test)] -mod pool_config_tests { +mod tests { use super::{PostgresPoolConfig, PostgresPoolConfigError}; #[test] - fn pool_config_uses_explicit_defaults() { - let config = PostgresPoolConfig::from_vars(std::iter::empty::<(&str, &str)>()).unwrap(); - + fn explicit_defaults_remain_valid() { assert_eq!( - config, - PostgresPoolConfig { - max_connections: 20, - min_connections: 2, - acquire_timeout_ms: 5_000, - idle_timeout_ms: 600_000, - max_lifetime_ms: 1_800_000, - } + PostgresPoolConfig::try_new(20, 2, 5_000, 600_000, 1_800_000).unwrap(), + PostgresPoolConfig::default() ); } #[test] - fn pool_config_parses_overrides() { - let config = PostgresPoolConfig::from_vars([ - ("POSTGRES_MAX_CONNECTIONS", "32"), - ("POSTGRES_MIN_CONNECTIONS", "4"), - ("POSTGRES_ACQUIRE_TIMEOUT_MS", "7000"), - ("POSTGRES_IDLE_TIMEOUT_MS", "900000"), - ("POSTGRES_MAX_LIFETIME_MS", "3600000"), - ]) - .unwrap(); - + fn rejects_invalid_bounds_and_cross_fields() { assert_eq!( - config, - PostgresPoolConfig { - max_connections: 32, - min_connections: 4, - acquire_timeout_ms: 7_000, - idle_timeout_ms: 900_000, - max_lifetime_ms: 3_600_000, + PostgresPoolConfig::try_new(0, 0, 5_000, 600_000, 1_800_000).unwrap_err(), + PostgresPoolConfigError::OutOfRange { + field: "max_connections" } ); - } - - #[test] - fn pool_config_rejects_invalid_numeric_value() { - let error = - PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "abc")]).unwrap_err(); - assert_eq!( - error, - PostgresPoolConfigError::InvalidValue { - name: "POSTGRES_MAX_CONNECTIONS", - value: "abc".to_owned(), - } + PostgresPoolConfig::try_new(2, 3, 5_000, 600_000, 1_800_000).unwrap_err(), + PostgresPoolConfigError::MinConnectionsExceedMax ); } - - #[test] - fn pool_config_rejects_zero_max_connections() { - let error = PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "0")]).unwrap_err(); - - assert_eq!(error, PostgresPoolConfigError::ZeroMaxConnections); - } - - #[test] - fn pool_config_rejects_min_connections_above_max() { - let error = PostgresPoolConfig::from_vars([ - ("POSTGRES_MAX_CONNECTIONS", "2"), - ("POSTGRES_MIN_CONNECTIONS", "3"), - ]) - .unwrap_err(); - - assert_eq!(error, PostgresPoolConfigError::MinConnectionsExceedMax); - } } diff --git a/crates/crank-registry/tests/integration/common.rs b/crates/crank-registry/tests/integration/common.rs index ecb66c0..6e0f5c3 100644 --- a/crates/crank-registry/tests/integration/common.rs +++ b/crates/crank-registry/tests/integration/common.rs @@ -216,6 +216,7 @@ pub(super) fn test_invocation_log( tool_name: "create_lead".to_owned(), message: "invocation".to_owned(), request_id: Some(format!("req_{id}")), + trace_id: Some("0af7651916cd43dd8448eb211c80319c".to_owned()), status_code: Some(200), duration_ms, error_kind: None, @@ -256,12 +257,15 @@ impl TestDatabase { } pub(super) async fn registry(&self) -> PostgresRegistry { - PostgresRegistry::connect(&format!( + let database_url = format!( "{}?options=-csearch_path%3D{}", self.database_url, self.schema - )) - .await - .unwrap() + ); + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + crank_registry::MigrationAuthority::apply(&pool) + .await + .unwrap(); + PostgresRegistry::connect(&database_url).await.unwrap() } pub(super) async fn cleanup(&self) { diff --git a/crates/crank-registry/tests/integration/migrations.rs b/crates/crank-registry/tests/integration/migrations.rs index 2b1b8e7..0f3a420 100644 --- a/crates/crank-registry/tests/integration/migrations.rs +++ b/crates/crank-registry/tests/integration/migrations.rs @@ -1,34 +1,46 @@ -use crank_registry::PostgresRegistry; +use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry}; use sqlx::Row; +static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] -async fn core_migration_is_versioned_and_safe_under_concurrent_startup() { +async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() { let database_url = crank_test_support::postgres_schema_url("test_core_migration").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); let (first, second) = tokio::join!( - PostgresRegistry::connect(&database_url), - PostgresRegistry::connect(&database_url), + MigrationAuthority::apply(&pool), + MigrationAuthority::apply(&pool), ); - let first = first.expect("first service startup must apply the migration"); - second.expect("second service startup must observe the applied migration"); + first.expect("first controlled runner must apply the sequence"); + second.expect("second controlled runner must observe the applied sequence"); - let rows = sqlx::query( - "select version, description, checksum from __crank_core_migrations order by version", - ) - .fetch_all(first.pool()) - .await - .expect("migration ledger must be readable"); + let first = PostgresRegistry::connect(&database_url) + .await + .expect("service startup must verify the migrated schema"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].get::("version"), 1); - assert_eq!( - rows[0].get::("description"), - "community baseline" - ); + let rows = + sqlx::query("select version, name, checksum from __crank_migrations order by version") + .fetch_all(first.pool()) + .await + .expect("migration ledger must be readable"); + + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].get::("version"), 1); + assert_eq!(rows[0].get::("name"), "community-baseline-v1"); assert_eq!( rows[0].get::("checksum"), "crank-community-baseline-v1" ); + assert_eq!(rows[1].get::("version"), 2); + assert_eq!(rows[1].get::("name"), "legacy-consolidation-v2"); + assert_eq!(rows[1].get::("checksum").len(), 64); + assert_eq!(rows[2].get::("version"), 3); + assert_eq!( + rows[2].get::("name"), + "request-trace-identity-v3" + ); + assert_eq!(rows[2].get::("checksum").len(), 64); let approval_columns = sqlx::query( "select column_name @@ -41,4 +53,657 @@ async fn core_migration_is_versioned_and_safe_under_concurrent_startup() { .await .expect("approval schema must be readable"); assert_eq!(approval_columns.len(), 3); + + assert_eq!( + MigrationAuthority::preflight(first.pool()).await.unwrap(), + MigrationPreflight::Current { version: 3 } + ); +} + +#[tokio::test] +async fn service_connect_is_read_only_on_fresh_database() { + let database_url = crank_test_support::postgres_schema_url("test_read_only_startup").await; + + let error = PostgresRegistry::connect(&database_url) + .await + .expect_err("fresh schema must require the controlled migration command"); + assert!(error.to_string().contains("schema_missing")); + + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let ledger = sqlx::query("select to_regclass('__crank_migrations')::text as name") + .fetch_one(&pool) + .await + .unwrap() + .try_get::, _>("name") + .unwrap(); + assert_eq!( + ledger, None, + "startup compatibility check must not create DDL" + ); +} + +#[tokio::test] +async fn changed_checksum_fails_closed_without_repair() { + let database_url = crank_test_support::postgres_schema_url("test_changed_checksum").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("update __crank_migrations set checksum = 'changed' where version = 1") + .execute(&pool) + .await + .unwrap(); + + let error = MigrationAuthority::apply(&pool) + .await + .expect_err("published checksum mismatch must fail closed"); + assert_eq!(error.code(), "checksum_mismatch"); + let checksum = sqlx::query("select checksum from __crank_migrations where version = 1") + .fetch_one(&pool) + .await + .unwrap() + .get::("checksum"); + assert_eq!( + checksum, "changed", + "authority must not rewrite corrupt history" + ); +} + +#[tokio::test] +async fn legacy_core_baseline_is_consolidated_without_data_loss() { + let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query( + "insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at) + values ('ws_preserved', 'preserved', 'Preserved', 'active', '{}'::jsonb, now(), now())", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::raw_sql( + "insert into operations + (id, workspace_id, name, display_name, protocol, status, created_at, updated_at) + values ('op_preserved', 'ws_default', 'preserved', 'Preserved', 'rest', 'draft', now(), now()); + insert into operation_versions + (operation_id, version, status, target_json, input_schema_json, output_schema_json, + input_mapping_json, output_mapping_json, execution_config_json, + tool_description_json, created_at) + values ('op_preserved', 1, 'draft', '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, + '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, now()); + insert into agents + (id, workspace_id, slug, display_name, description, status, created_at, updated_at) + values ('agent_preserved', 'ws_default', 'preserved', 'Preserved', '', 'draft', now(), now()); + insert into agent_versions + (agent_id, version, status, instructions_json, tool_selection_policy_json, created_at) + values ('agent_preserved', 1, 'draft', '{}'::jsonb, '{}'::jsonb, now()); + insert into platform_api_keys + (id, workspace_id, agent_id, name, prefix, secret_hash, scopes_json, status, created_at) + values ('key_preserved', 'ws_default', 'agent_preserved', 'Preserved', 'cp_', 'hash', '[]'::jsonb, 'active', now()); + insert into approval_requests + (id, workspace_id, agent_id, operation_id, operation_version, status, risk_level, + request_payload_json, created_at, expires_at) + values ('approval_preserved', 'ws_default', 'agent_preserved', 'op_preserved', 1, + 'pending', 'high', '{}'::jsonb, now(), now() + interval '1 hour'); + insert into invocation_logs + (id, workspace_id, agent_id, operation_id, source, level, status, tool_name, + message, duration_ms, request_preview_json, response_preview_json, created_at) + values ('log_preserved', 'ws_default', 'agent_preserved', 'op_preserved', 'mcp', + 'info', 'success', 'preserved', 'safe', 1, '{}'::jsonb, '{}'::jsonb, now());", + ) + .execute(&pool) + .await + .unwrap(); + remove_v3_schema(&pool).await; + let tables = [ + "operations", + "operation_versions", + "agents", + "agent_versions", + "platform_api_keys", + "approval_requests", + "invocation_logs", + ]; + let mut before = Vec::new(); + for table in tables { + let row = if table == "invocation_logs" { + "to_jsonb(t) - 'trace_id'" + } else { + "to_jsonb(t)" + }; + let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t"); + before.push( + sqlx::query_scalar::<_, Option>(sqlx::AssertSqlSafe(sql)) + .fetch_one(&pool) + .await + .unwrap(), + ); + } + sqlx::query( + "drop table __crank_migrations, __crank_migration_legacy_audit, + __crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations", + ) + .execute(&pool) + .await + .unwrap(); + + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::MigrationRequired { + current: 1, + target: 3, + } + ); + MigrationAuthority::apply(&pool).await.unwrap(); + let display_name = sqlx::query("select display_name from workspaces where id = 'ws_preserved'") + .fetch_one(&pool) + .await + .unwrap() + .get::("display_name"); + assert_eq!(display_name, "Preserved"); + let mut after = Vec::new(); + for table in tables { + let row = if table == "invocation_logs" { + "to_jsonb(t) - 'trace_id'" + } else { + "to_jsonb(t)" + }; + let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t"); + after.push( + sqlx::query_scalar::<_, Option>(sqlx::AssertSqlSafe(sql)) + .fetch_one(&pool) + .await + .unwrap(), + ); + } + assert_eq!(before, after, "brownfield rows must remain byte-equivalent"); +} + +#[tokio::test] +async fn legacy_mcp_sessions_survive_consolidation() { + let database_url = crank_test_support::postgres_schema_url("test_legacy_mcp").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query( + "insert into mcp_transport_sessions ( + id, protocol_version, initialized, supports_elicitation, + workspace_slug, agent_slug, created_at, updated_at, expires_at + ) values ('session_preserved', '2025-11-25', true, false, + 'default', 'agent', now(), now(), null)", + ) + .execute(&pool) + .await + .unwrap(); + remove_v3_schema(&pool).await; + sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit") + .execute(&pool) + .await + .unwrap(); + + MigrationAuthority::apply(&pool).await.unwrap(); + let count = sqlx::query("select count(*)::bigint as count from mcp_transport_sessions where id = 'session_preserved'") + .fetch_one(&pool) + .await + .unwrap() + .get::("count"); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn repeated_apply_does_not_rewrite_audit_timestamps() { + let database_url = crank_test_support::postgres_schema_url("test_repeat_apply").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + let before = sqlx::query("select applied_at from __crank_migrations order by version") + .fetch_all(&pool) + .await + .unwrap() + .into_iter() + .map(|row| row.get::("applied_at")) + .collect::>(); + + MigrationAuthority::apply(&pool).await.unwrap(); + let after = sqlx::query("select applied_at from __crank_migrations order by version") + .fetch_all(&pool) + .await + .unwrap() + .into_iter() + .map(|row| row.get::("applied_at")) + .collect::>(); + assert_eq!(before, after); +} + +#[tokio::test] +async fn future_sequence_fails_closed() { + let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("update __crank_migrations set version = 4 where version = 3") + .execute(&pool) + .await + .unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool) + .await + .unwrap_err() + .code(), + "future_version" + ); +} + +#[tokio::test] +async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() { + let database_url = crank_test_support::postgres_schema_url("test_v2_to_v3_identity").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("delete from __crank_migrations where version = 3") + .execute(&pool) + .await + .unwrap(); + remove_v3_schema(&pool).await; + + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::MigrationRequired { + current: 2, + target: 3, + } + ); + MigrationAuthority::apply(&pool).await.unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::Current { version: 3 } + ); + let trace_column: bool = sqlx::query_scalar( + "select exists ( + select 1 from information_schema.columns + where table_schema = current_schema() + and table_name = 'invocation_logs' + and column_name = 'trace_id' + )", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(trace_column); +} + +#[tokio::test] +async fn v2_with_partial_v3_objects_fails_before_apply() { + let database_url = crank_test_support::postgres_schema_url("test_v2_partial_v3").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("delete from __crank_migrations where version = 3") + .execute(&pool) + .await + .unwrap(); + sqlx::raw_sql( + "drop index invocation_logs_workspace_request_id_idx; + drop index invocation_logs_workspace_trace_id_idx; + alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;", + ) + .execute(&pool) + .await + .unwrap(); + + let error = MigrationAuthority::preflight(&pool).await.unwrap_err(); + assert_eq!(error.code(), "partial_sequence"); + assert_eq!(error.stage(), "preflight.schema"); +} + +#[tokio::test] +async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() { + let database_url = crank_test_support::postgres_schema_url("test_v3_named_drift").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::raw_sql( + "alter table invocation_logs drop constraint invocation_logs_trace_id_format_check; + alter table invocation_logs add constraint invocation_logs_trace_id_format_check + check (true) not valid;", + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool) + .await + .unwrap_err() + .code(), + "partial_sequence" + ); + + sqlx::raw_sql( + "alter table invocation_logs drop constraint invocation_logs_trace_id_format_check; + alter table invocation_logs add constraint invocation_logs_trace_id_format_check + check ( + trace_id is null or ( + trace_id ~ '^[0-9a-f]{32}$' + and trace_id <> '00000000000000000000000000000000' + ) + ) not valid; + drop index invocation_logs_workspace_trace_id_idx; + create index invocation_logs_workspace_trace_id_idx on invocation_logs(trace_id);", + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool) + .await + .unwrap_err() + .code(), + "partial_sequence" + ); +} + +#[tokio::test] +async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() { + let database_url = crank_test_support::postgres_schema_url("test_v3_legacy_request_id").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::raw_sql( + "insert into operations + (id, workspace_id, name, display_name, protocol, status, created_at, updated_at) + values ('op_legacy_request', 'ws_default', 'legacy-request', 'Legacy Request', + 'rest', 'draft', now(), now());", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "insert into invocation_logs + (id, workspace_id, operation_id, source, level, status, tool_name, message, + request_id, duration_ms, request_preview_json, response_preview_json, created_at) + values ('legacy_request_log', 'ws_default', 'op_legacy_request', 'admin', 'info', + 'success', 'legacy_request', 'safe', $1, 1, '{}'::jsonb, '{}'::jsonb, now())", + ) + .bind("x".repeat(10_000)) + .execute(&pool) + .await + .unwrap(); + sqlx::query("delete from __crank_migrations where version = 3") + .execute(&pool) + .await + .unwrap(); + remove_v3_schema(&pool).await; + + MigrationAuthority::apply(&pool).await.unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool).await.unwrap(), + MigrationPreflight::Current { version: 3 } + ); +} + +async fn remove_v3_schema(pool: &sqlx::PgPool) { + sqlx::raw_sql( + "drop index if exists invocation_logs_workspace_request_id_idx; + alter table invocation_logs drop column if exists trace_id;", + ) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn partial_sequence_fails_closed() { + let database_url = crank_test_support::postgres_schema_url("test_partial_sequence").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("delete from __crank_migrations where version = 1") + .execute(&pool) + .await + .unwrap(); + assert_eq!( + MigrationAuthority::preflight(&pool) + .await + .unwrap_err() + .code(), + "partial_sequence" + ); +} + +#[tokio::test] +async fn missing_relation_with_current_ledger_fails_closed() { + let database_url = crank_test_support::postgres_schema_url("test_missing_relation").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("drop table usage_rollups") + .execute(&pool) + .await + .unwrap(); + let error = MigrationAuthority::preflight(&pool).await.unwrap_err(); + assert_eq!(error.code(), "partial_sequence"); + assert_eq!(error.stage(), "preflight.schema"); +} + +#[tokio::test] +async fn unregistered_legacy_extension_provenance_is_rejected() { + let database_url = crank_test_support::postgres_schema_url("test_legacy_extension").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit") + .execute(&pool) + .await + .unwrap(); + let checksum = "a".repeat(64); + sqlx::query( + "insert into __crank_ext_migrations (extension_name, version, checksum) + values ('known-extension', 1, $1)", + ) + .bind(&checksum) + .execute(&pool) + .await + .unwrap(); + + let error = MigrationAuthority::apply(&pool).await.unwrap_err(); + assert_eq!(error.code(), "legacy_conflict"); +} + +#[tokio::test] +async fn any_owned_relation_without_core_ledger_is_partial() { + let database_url = crank_test_support::postgres_schema_url("test_owned_partial").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + sqlx::query("create table users (id text primary key)") + .execute(&pool) + .await + .unwrap(); + let error = MigrationAuthority::preflight(&pool).await.unwrap_err(); + assert_eq!(error.code(), "partial_sequence"); +} + +#[tokio::test] +async fn current_ledger_with_structural_drift_fails_closed() { + let database_url = crank_test_support::postgres_schema_url("test_structural_drift").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query("alter table mcp_transport_sessions drop column supports_elicitation") + .execute(&pool) + .await + .unwrap(); + let error = MigrationAuthority::preflight(&pool).await.unwrap_err(); + assert_eq!(error.code(), "partial_sequence"); + assert_eq!(error.stage(), "preflight.schema"); +} + +#[tokio::test] +async fn tampered_legacy_audit_fails_closed() { + let database_url = crank_test_support::postgres_schema_url("test_tampered_audit").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query( + "update __crank_migration_legacy_audit set source_checksum = 'tampered' where source = 'core'", + ) + .execute(&pool) + .await + .unwrap(); + let error = MigrationAuthority::preflight(&pool).await.unwrap_err(); + assert_eq!(error.code(), "legacy_conflict"); +} + +#[tokio::test] +async fn failed_consolidation_rolls_back_all_changes() { + let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await; + let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::query( + "insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at) + values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "drop table __crank_migrations, __crank_migration_legacy_audit, + __crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations", + ) + .execute(&pool) + .await + .unwrap(); + let schema: String = sqlx::query_scalar("select current_schema()") + .fetch_one(&pool) + .await + .unwrap(); + let failure_trigger = format!( + "create function reject_story14_v2() returns event_trigger language plpgsql as $$ + begin + if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then + raise exception 'injected v2 ddl failure'; + end if; + end $$; + create event trigger reject_story14_v2 on ddl_command_start + execute function reject_story14_v2();" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger)) + .execute(&pool) + .await + .unwrap(); + + let error = MigrationAuthority::apply(&pool).await.unwrap_err(); + sqlx::raw_sql( + "drop event trigger reject_story14_v2; + drop function reject_story14_v2();", + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!(error.code(), "apply_failed"); + + for relation in [ + "__crank_migrations", + "__crank_migration_legacy_audit", + "__crank_mcp_migrations", + "mcp_transport_sessions", + "__crank_ext_migrations", + ] { + let present: bool = sqlx::query_scalar( + "select to_regclass(format('%I.%I', current_schema(), $1)) is not null", + ) + .bind(relation) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!present, "{relation} must roll back with failed v2 DDL"); + } + let preserved: String = + sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(preserved, "Rollback Preserved"); +} + +#[tokio::test] +async fn failed_request_trace_identity_migration_rolls_back_all_changes() { + let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await; + let database_url = + crank_test_support::postgres_schema_url("test_trace_identity_rollback").await; + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + MigrationAuthority::apply(&pool).await.unwrap(); + sqlx::raw_sql( + "insert into operations + (id, workspace_id, name, display_name, protocol, status, created_at, updated_at) + values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback', + 'rest', 'draft', now(), now()); + insert into invocation_logs + (id, workspace_id, operation_id, source, level, status, tool_name, message, + duration_ms, request_preview_json, response_preview_json, created_at) + values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin', + 'info', 'success', 'trace_rollback', 'safe preserved row', 1, + '{}'::jsonb, '{}'::jsonb, now());", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("delete from __crank_migrations where version = 3") + .execute(&pool) + .await + .unwrap(); + remove_v3_schema(&pool).await; + let schema: String = sqlx::query_scalar("select current_schema()") + .fetch_one(&pool) + .await + .unwrap(); + let failure_trigger = format!( + "create function reject_story15_v3() returns event_trigger language plpgsql as $$ + begin + if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then + raise exception 'injected v3 ddl failure'; + end if; + end $$; + create event trigger reject_story15_v3 on ddl_command_start + execute function reject_story15_v3();" + ); + sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger)) + .execute(&pool) + .await + .unwrap(); + + let error = MigrationAuthority::apply(&pool).await.unwrap_err(); + sqlx::raw_sql( + "drop event trigger reject_story15_v3; + drop function reject_story15_v3();", + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!(error.code(), "apply_failed"); + assert_eq!(error.version(), Some(3)); + + let trace_column: bool = sqlx::query_scalar( + "select exists ( + select 1 from information_schema.columns + where table_schema = current_schema() + and table_name = 'invocation_logs' + and column_name = 'trace_id' + )", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!trace_column, "trace_id column must roll back with v3"); + for index in [ + "invocation_logs_workspace_request_id_idx", + "invocation_logs_workspace_trace_id_idx", + ] { + let present: bool = sqlx::query_scalar( + "select to_regclass(format('%I.%I', current_schema(), $1)) is not null", + ) + .bind(index) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!present, "{index} must roll back with failed v3 DDL"); + } + let ledger_v3: i64 = + sqlx::query_scalar("select count(*) from __crank_migrations where version = 3") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied"); + let preserved: String = sqlx::query_scalar( + "select message from invocation_logs where id = 'trace_rollback_preserved'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(preserved, "safe preserved row"); } diff --git a/crates/crank-runtime/src/cache.rs b/crates/crank-runtime/src/cache.rs index e52ce4c..33bdcb2 100644 --- a/crates/crank-runtime/src/cache.rs +++ b/crates/crank-runtime/src/cache.rs @@ -1,7 +1,5 @@ use std::{ collections::HashMap, - env, - num::ParseIntError, sync::Arc, time::{Duration, Instant}, }; @@ -21,7 +19,6 @@ use tokio::sync::RwLock; pub struct RuntimeCacheConfig { pub backend: CacheBackend, pub url: Option, - pub default_ttl_ms: Option, } impl Default for RuntimeCacheConfig { @@ -29,26 +26,22 @@ impl Default for RuntimeCacheConfig { Self { backend: CacheBackend::Memory, url: None, - default_ttl_ms: None, } } } impl RuntimeCacheConfig { - pub fn from_env() -> Result { - let backend = parse_backend()?; - let url = parse_optional_string("CRANK_CACHE_URL")?; - let default_ttl_ms = parse_optional_u64("CRANK_CACHE_DEFAULT_TTL_MS")?; - + pub fn try_new( + backend: CacheBackend, + url: Option, + ) -> Result { if backend.is_external() && url.is_none() { return Err(RuntimeCacheConfigError::MissingUrl { backend }); } - - Ok(Self { - backend, - url, - default_ttl_ms, - }) + if !backend.is_external() && url.is_some() { + return Err(RuntimeCacheConfigError::UnexpectedUrl); + } + Ok(Self { backend, url }) } } @@ -137,17 +130,11 @@ impl RedisCacheStore { url: &str, ) -> Result { let client = - Client::open(url).map_err(|source| RuntimeCacheStoreInitError::InvalidUrl { - backend, - url: url.to_owned(), - details: source.to_string(), - })?; - let connection_manager = client.get_connection_manager().await.map_err(|source| { - RuntimeCacheStoreInitError::ConnectFailed { - backend, - details: source.to_string(), - } - })?; + Client::open(url).map_err(|_| RuntimeCacheStoreInitError::InvalidUrl { backend })?; + let connection_manager = client + .get_connection_manager() + .await + .map_err(|_| RuntimeCacheStoreInitError::ConnectFailed { backend })?; Ok(Self { backend, connection_manager, @@ -774,86 +761,20 @@ fn scoped_key(scope: CacheScope, key: &str) -> String { format!("{scope:?}:{key}") } -fn parse_backend() -> Result { - match env::var("CRANK_CACHE_BACKEND") { - Ok(raw) => raw - .parse::() - .map_err(|source| RuntimeCacheConfigError::InvalidBackend { value: raw, source }), - Err(env::VarError::NotPresent) => Ok(CacheBackend::Memory), - Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode { - name: "CRANK_CACHE_BACKEND", - }), - } -} - -fn parse_optional_string(name: &'static str) -> Result, RuntimeCacheConfigError> { - match env::var(name) { - Ok(raw) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_owned())) - } - } - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode { name }), - } -} - -fn parse_optional_u64(name: &'static str) -> Result, RuntimeCacheConfigError> { - match env::var(name) { - Ok(raw) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(None); - } - let value = trimmed - .parse::() - .map_err(|source| RuntimeCacheConfigError::InvalidTtl { value: raw, source })?; - if value == 0 { - return Err(RuntimeCacheConfigError::ZeroTtl { name }); - } - Ok(Some(value)) - } - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode { name }), - } -} - #[derive(Debug, Error)] pub enum RuntimeCacheConfigError { - #[error("{name} must contain valid UTF-8")] - InvalidUnicode { name: &'static str }, - #[error("CRANK_CACHE_BACKEND must be one of memory, valkey, redis, got {value}")] - InvalidBackend { - value: String, - source: crank_core::ParseCacheBackendError, - }, - #[error("CRANK_CACHE_DEFAULT_TTL_MS must be a positive integer, got {value}")] - InvalidTtl { - value: String, - source: ParseIntError, - }, - #[error("{name} must be greater than zero")] - ZeroTtl { name: &'static str }, #[error("{backend} backend requires CRANK_CACHE_URL")] MissingUrl { backend: CacheBackend }, + #[error("memory cache backend does not accept CRANK_CACHE_URL")] + UnexpectedUrl, } #[derive(Debug, Error, PartialEq, Eq)] pub enum RuntimeCacheStoreInitError { #[error("{backend} backend requires CRANK_CACHE_URL")] MissingUrl { backend: CacheBackend }, - #[error("invalid {backend} cache url {url}: {details}")] - InvalidUrl { - backend: CacheBackend, - url: String, - details: String, - }, - #[error("failed to connect to {backend} cache backend: {details}")] - ConnectFailed { - backend: CacheBackend, - details: String, - }, + #[error("invalid {backend} cache url")] + InvalidUrl { backend: CacheBackend }, + #[error("failed to connect to {backend} cache backend")] + ConnectFailed { backend: CacheBackend }, } diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 5839e47..cc8fd5d 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -11,7 +11,9 @@ use crank_metrics::{ ToolOutcome, record_cache_outcome, record_confirmation_outcome, record_idempotency_outcome, record_limit_rejection, }; -use crank_trace::{ErrorCategory, Stage, StageOutcome}; +use crank_trace::{ + ErrorCategory, Stage, StageOutcome, set_parent_from_trace_context, trace_context_for_span, +}; use serde_json::{Map, Value, json}; use time::OffsetDateTime; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; @@ -242,11 +244,22 @@ impl RuntimeExecutor { &self, request: RuntimeExecutionRequest<'_>, ) -> Result { - log_runtime_event("unary.execute", request.operation, request.request_context); + let runtime_span = Stage::RuntimeExecute.span(); + if let Some(context) = request.request_context { + set_parent_from_trace_context(&runtime_span, &context.trace_context); + } + let generated_context = request.request_context.is_none().then(|| { + RuntimeRequestContext::new( + crank_core::RequestId::generate(), + trace_context_for_span(&runtime_span) + .unwrap_or_else(crank_core::TraceContext::generate), + ) + }); + let request_context = request.request_context.or(generated_context.as_ref()); + log_runtime_event("unary.execute", request.operation, request_context); let started_at = Instant::now(); let invocation_metrics = - ToolInvocationMetrics::start(metric_invocation_source(request.request_context)); - let runtime_span = Stage::RuntimeExecute.span(); + ToolInvocationMetrics::start(metric_invocation_source(request_context)); let result = async { let _permit = self.acquire_unary_permit(request.operation)?; let _inflight = InFlightGuard::runtime(); @@ -261,7 +274,7 @@ impl RuntimeExecutor { request.operation, request.input, prepared_request, - request.request_context, + request_context, ) .await } @@ -274,13 +287,8 @@ impl RuntimeExecutor { Err(error) => (ToolOutcome::Error, runtime_error_kind(error)), }; invocation_metrics.complete(outcome, error_kind); - self.record_metering( - request.operation, - request.request_context, - &result, - started_at, - ) - .await; + self.record_metering(request.operation, request_context, &result, started_at) + .await; result } @@ -829,7 +837,7 @@ fn log_runtime_event( operation_id = operation.operation_id.as_str(), protocol, request_id = context.request_id.as_str(), - correlation_id = context.correlation_id.as_str(), + trace_id = context.trace_context.trace_id().as_str(), "runtime execution" ); } else { diff --git a/crates/crank-runtime/src/executor_builder.rs b/crates/crank-runtime/src/executor_builder.rs index 5d43804..eb043d6 100644 --- a/crates/crank-runtime/src/executor_builder.rs +++ b/crates/crank-runtime/src/executor_builder.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError}; +use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter}; use crank_core::{ AdapterRegistry, CoordinationStateStore, MeteringSink, NoopMeteringSink, ResponseCacheStore, SharedMeteringSink, SharedProtocolAdapter, @@ -74,11 +74,6 @@ pub fn community_default() -> RuntimeExecutorBuilder { .register_adapter(Arc::new(RestAdapter::new()) as SharedProtocolAdapter) } -pub fn community_from_env() -> Result { - Ok(RuntimeExecutorBuilder::new() - .register_adapter(Arc::new(RestAdapter::from_env()?) as SharedProtocolAdapter)) -} - pub fn community_with_outbound_policy(policy: OutboundHttpPolicy) -> RuntimeExecutorBuilder { RuntimeExecutorBuilder::new() .register_adapter(Arc::new(RestAdapter::with_policy(policy)) as SharedProtocolAdapter) diff --git a/crates/crank-runtime/src/lib.rs b/crates/crank-runtime/src/lib.rs index f5d182b..1914ada 100644 --- a/crates/crank-runtime/src/lib.rs +++ b/crates/crank-runtime/src/lib.rs @@ -27,7 +27,7 @@ pub use crank_adapter_rest::OutboundHttpPolicy; pub use error::RuntimeError; pub use executor::{RuntimeExecutionRequest, RuntimeExecutor}; pub use executor_builder::{ - RuntimeExecutorBuilder, community_default, community_from_env, community_with_outbound_policy, + RuntimeExecutorBuilder, community_default, community_with_outbound_policy, }; pub use limits::{RuntimeLimits, RuntimeLimitsConfigError}; pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation}; diff --git a/crates/crank-runtime/src/limits.rs b/crates/crank-runtime/src/limits.rs index 0293033..5530bb2 100644 --- a/crates/crank-runtime/src/limits.rs +++ b/crates/crank-runtime/src/limits.rs @@ -1,9 +1,8 @@ -use std::{env, num::ParseIntError}; - use thiserror::Error; const DEFAULT_MAX_CONCURRENT_UNARY: usize = 64; const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 16; +const MAX_CONCURRENCY: usize = 65_535; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RuntimeLimits { @@ -21,52 +20,31 @@ impl Default for RuntimeLimits { } impl RuntimeLimits { - pub fn from_env() -> Result { + pub fn try_new( + max_concurrent_unary: usize, + max_concurrent_sessions: usize, + ) -> Result { + if !(1..=MAX_CONCURRENCY).contains(&max_concurrent_unary) { + return Err(RuntimeLimitsConfigError::OutOfRange { + field: "runtime.max_concurrent_unary", + }); + } + if !(1..=MAX_CONCURRENCY).contains(&max_concurrent_sessions) { + return Err(RuntimeLimitsConfigError::OutOfRange { + field: "runtime.max_concurrent_sessions", + }); + } Ok(Self { - max_concurrent_unary: parse_limit( - "CRANK_RUNTIME_MAX_CONCURRENT_UNARY", - DEFAULT_MAX_CONCURRENT_UNARY, - )?, - max_concurrent_sessions: parse_limit( - "CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS", - DEFAULT_MAX_CONCURRENT_SESSIONS, - )?, + max_concurrent_unary, + max_concurrent_sessions, }) } } -fn parse_limit(name: &'static str, default: usize) -> Result { - match env::var(name) { - Ok(raw) => { - let value = - raw.parse::() - .map_err(|source| RuntimeLimitsConfigError::InvalidValue { - name, - value: raw, - source, - })?; - if value == 0 { - return Err(RuntimeLimitsConfigError::ZeroValue { name }); - } - Ok(value) - } - Err(env::VarError::NotPresent) => Ok(default), - Err(env::VarError::NotUnicode(_)) => Err(RuntimeLimitsConfigError::InvalidUnicode { name }), - } -} - -#[derive(Debug, Error)] +#[derive(Debug, Error, Eq, PartialEq)] pub enum RuntimeLimitsConfigError { - #[error("{name} must contain valid UTF-8")] - InvalidUnicode { name: &'static str }, - #[error("{name} must be a positive integer, got {value}")] - InvalidValue { - name: &'static str, - value: String, - source: ParseIntError, - }, - #[error("{name} must be greater than zero")] - ZeroValue { name: &'static str }, + #[error("runtime limit is outside its allowed bounds: {field}")] + OutOfRange { field: &'static str }, } #[cfg(test)] @@ -76,28 +54,17 @@ mod tests { #[test] fn defaults_are_positive() { let limits = RuntimeLimits::default(); - assert!(limits.max_concurrent_unary > 0); assert!(limits.max_concurrent_sessions > 0); } #[test] - fn rejects_zero_limit_values() { - unsafe { - std::env::set_var("CRANK_RUNTIME_MAX_CONCURRENT_UNARY", "0"); - } - - let error = RuntimeLimits::from_env().unwrap_err(); - - assert!(matches!( - error, - RuntimeLimitsConfigError::ZeroValue { - name: "CRANK_RUNTIME_MAX_CONCURRENT_UNARY" + fn value_constructor_rejects_zero() { + assert_eq!( + RuntimeLimits::try_new(0, 1).unwrap_err(), + RuntimeLimitsConfigError::OutOfRange { + field: "runtime.max_concurrent_unary" } - )); - - unsafe { - std::env::remove_var("CRANK_RUNTIME_MAX_CONCURRENT_UNARY"); - } + ); } } diff --git a/crates/crank-runtime/src/request_context.rs b/crates/crank-runtime/src/request_context.rs index b3b1583..4614efd 100644 --- a/crates/crank-runtime/src/request_context.rs +++ b/crates/crank-runtime/src/request_context.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; -use crank_core::{AgentId, InvocationSource, WorkspaceId}; +use crank_core::{ + AgentId, CorrelationContext, InvocationSource, RequestId, TraceContext, WorkspaceId, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResponseCacheScope { @@ -10,8 +12,8 @@ pub struct ResponseCacheScope { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeRequestContext { - pub request_id: String, - pub correlation_id: String, + pub request_id: RequestId, + pub trace_context: TraceContext, pub response_cache_scope: Option, pub metering_context: Option, pub confirmation_token: Option, @@ -26,10 +28,10 @@ pub struct MeteringContext { } impl RuntimeRequestContext { - pub fn new(request_id: impl Into, correlation_id: impl Into) -> Self { + pub fn new(request_id: RequestId, trace_context: TraceContext) -> Self { Self { - request_id: request_id.into(), - correlation_id: correlation_id.into(), + request_id, + trace_context, response_cache_scope: None, metering_context: None, confirmation_token: None, @@ -39,13 +41,31 @@ impl RuntimeRequestContext { pub fn from_request_id(request_id: impl Into) -> Self { let request_id = request_id.into(); - Self::new(request_id.clone(), request_id) + Self::new( + RequestId::resolve(Some(&request_id)), + TraceContext::generate(), + ) + } + + pub fn from_correlation(context: &CorrelationContext) -> Self { + Self::new( + context.request_id().clone(), + context.trace_context().clone(), + ) } pub fn outbound_headers(&self) -> BTreeMap { BTreeMap::from([ - ("x-request-id".to_owned(), self.request_id.clone()), - ("x-correlation-id".to_owned(), self.correlation_id.clone()), + ("x-request-id".to_owned(), self.request_id.to_string()), + ( + "x-trace-id".to_owned(), + self.trace_context.trace_id().to_string(), + ), + ( + "traceparent".to_owned(), + self.trace_context.traceparent().to_owned(), + ), + ("x-correlation-id".to_owned(), self.request_id.to_string()), ]) } @@ -124,7 +144,7 @@ impl From<&RuntimeRequestContext> for crank_core::RuntimeRequestContext { fn from(value: &RuntimeRequestContext) -> Self { Self { request_id: value.request_id.clone(), - correlation_id: value.correlation_id.clone(), + trace_context: value.trace_context.clone(), response_cache_scope: value.response_cache_scope.as_ref().map(Into::into), metering_context: value.metering_context.as_ref().map(Into::into), } @@ -158,11 +178,11 @@ mod tests { use super::RuntimeRequestContext; #[test] - fn uses_request_id_for_default_correlation_id() { + fn generates_a_separate_default_trace_identity() { let context = RuntimeRequestContext::from_request_id("req_123"); - assert_eq!(context.request_id, "req_123"); - assert_eq!(context.correlation_id, "req_123"); + assert_eq!(context.request_id.as_str(), "req_123"); + assert_ne!(context.trace_context.trace_id().as_str(), "req_123"); assert!(context.response_cache_scope.is_none()); assert!(context.confirmation_token.is_none()); assert!(!context.approval_granted()); diff --git a/crates/crank-runtime/tests/unit/cache.rs b/crates/crank-runtime/tests/unit/cache.rs index b95dfc3..896fbcf 100644 --- a/crates/crank-runtime/tests/unit/cache.rs +++ b/crates/crank-runtime/tests/unit/cache.rs @@ -1,8 +1,4 @@ -use std::{ - ffi::OsString, - sync::{Mutex, MutexGuard}, - time::Duration, -}; +use std::time::Duration; use crank_core::{ CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse, @@ -17,62 +13,29 @@ use crank_runtime::{ }; use serde_json::json; -const CACHE_ENV_NAMES: [&str; 3] = [ - "CRANK_CACHE_BACKEND", - "CRANK_CACHE_URL", - "CRANK_CACHE_DEFAULT_TTL_MS", -]; -static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn defaults_to_in_memory_cache_without_url() { let config = RuntimeCacheConfig::default(); assert_eq!(config.backend, CacheBackend::Memory); assert_eq!(config.url, None); - assert_eq!(config.default_ttl_ms, None); } #[test] -fn treats_blank_optional_cache_values_as_unset() { - let _env = IsolatedCacheEnv::new(); - unsafe { - std::env::set_var("CRANK_CACHE_URL", " "); - std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", " "); - } - - let config = RuntimeCacheConfig::from_env().unwrap(); - - assert_eq!(config.backend, CacheBackend::Memory); - assert_eq!(config.url, None); - assert_eq!(config.default_ttl_ms, None); -} - -#[test] -fn loads_valkey_config_from_env() { - let _env = IsolatedCacheEnv::new(); - unsafe { - std::env::set_var("CRANK_CACHE_BACKEND", "valkey"); - std::env::set_var("CRANK_CACHE_URL", "redis://cache:6379/0"); - std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "15000"); - } - - let config = RuntimeCacheConfig::from_env().unwrap(); +fn accepts_validated_valkey_values() { + let config = RuntimeCacheConfig::try_new( + CacheBackend::Valkey, + Some("redis://cache:6379/0".to_owned()), + ) + .unwrap(); assert_eq!(config.backend, CacheBackend::Valkey); assert_eq!(config.url.as_deref(), Some("redis://cache:6379/0")); - assert_eq!(config.default_ttl_ms, Some(15_000)); } #[test] fn rejects_external_backend_without_url() { - let _env = IsolatedCacheEnv::new(); - unsafe { - std::env::set_var("CRANK_CACHE_BACKEND", "redis"); - std::env::remove_var("CRANK_CACHE_URL"); - } - - let error = RuntimeCacheConfig::from_env().unwrap_err(); + let error = RuntimeCacheConfig::try_new(CacheBackend::Redis, None).unwrap_err(); assert!(matches!( error, @@ -83,59 +46,11 @@ fn rejects_external_backend_without_url() { } #[test] -fn rejects_zero_ttl() { - let _env = IsolatedCacheEnv::new(); - unsafe { - std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "0"); - } - - let error = RuntimeCacheConfig::from_env().unwrap_err(); - - assert!(matches!( - error, - RuntimeCacheConfigError::ZeroTtl { - name: "CRANK_CACHE_DEFAULT_TTL_MS" - } - )); -} - -struct IsolatedCacheEnv { - _lock: MutexGuard<'static, ()>, - previous: Vec<(&'static str, Option)>, -} - -impl IsolatedCacheEnv { - fn new() -> Self { - let lock = CACHE_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = CACHE_ENV_NAMES - .iter() - .map(|name| (*name, std::env::var_os(name))) - .collect(); - for name in CACHE_ENV_NAMES { - unsafe { - std::env::remove_var(name); - } - } - Self { - _lock: lock, - previous, - } - } -} - -impl Drop for IsolatedCacheEnv { - fn drop(&mut self) { - for (name, value) in &self.previous { - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - } +fn rejects_url_for_memory_backend() { + let error = + RuntimeCacheConfig::try_new(CacheBackend::Memory, Some("redis://cache:6379".to_owned())) + .unwrap_err(); + assert!(matches!(error, RuntimeCacheConfigError::UnexpectedUrl)); } #[tokio::test] @@ -410,7 +325,6 @@ fn runtime_cache_stores_report_missing_external_url() { let future = RuntimeCacheStores::from_config(&RuntimeCacheConfig { backend: CacheBackend::Valkey, url: None, - default_ttl_ms: None, }); let runtime = tokio::runtime::Runtime::new().unwrap(); diff --git a/crates/crank-trace/Cargo.toml b/crates/crank-trace/Cargo.toml index e3d9817..d26c0a8 100644 --- a/crates/crank-trace/Cargo.toml +++ b/crates/crank-trace/Cargo.toml @@ -10,7 +10,10 @@ version.workspace = true path = "src/lib.rs" [dependencies] +crank-core = { path = "../crank-core" } +opentelemetry.workspace = true tracing.workspace = true +tracing-opentelemetry.workspace = true [dev-dependencies] tracing-subscriber.workspace = true diff --git a/crates/crank-trace/src/lib.rs b/crates/crank-trace/src/lib.rs index 761e5c5..1408188 100644 --- a/crates/crank-trace/src/lib.rs +++ b/crates/crank-trace/src/lib.rs @@ -6,7 +6,50 @@ use std::future::Future; +use opentelemetry::{ + Context, + trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState}, +}; use tracing::{Instrument, Span, field::Empty, info_span}; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +pub fn set_parent_from_trace_context(span: &Span, context: &crank_core::TraceContext) -> bool { + let mut parts = context.traceparent().split('-'); + let (Some("00"), Some(trace_id), Some(parent_id), Some(flags), None) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return false; + }; + let (Ok(trace_id), Ok(parent_id)) = (TraceId::from_hex(trace_id), SpanId::from_hex(parent_id)) + else { + return false; + }; + let flags = match flags { + "00" => TraceFlags::default(), + "01" => TraceFlags::SAMPLED, + _ => return false, + }; + let parent = SpanContext::new(trace_id, parent_id, flags, true, TraceState::default()); + span.set_parent(Context::new().with_remote_span_context(parent)) + .is_ok() +} + +pub fn trace_context_for_span(span: &Span) -> Option { + let context = span.context(); + let span_context = context.span().span_context().clone(); + span_context.is_valid().then(|| { + crank_core::TraceContext::from_span_parts( + &span_context.trace_id().to_string(), + &span_context.span_id().to_string(), + span_context.is_sampled(), + ) + .ok() + })? +} macro_rules! stage_span { ($name:literal) => { diff --git a/deploy/community/.env.example b/deploy/community/.env.example index 4eb8663..cb17806 100644 --- a/deploy/community/.env.example +++ b/deploy/community/.env.example @@ -1,43 +1,34 @@ +# 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=change-me -POSTGRES_HOST=postgres.example.internal -POSTGRES_PORT=5432 +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 +CRANK_CACHE_BACKEND=memory +CRANK_CACHE_URL= CRANK_OUTBOUND_ALLOWED_HOSTS= CRANK_OUTBOUND_DENIED_HOSTS= CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304 -CRANK_CACHE_BACKEND=memory -CRANK_CACHE_URL= -CRANK_CACHE_DEFAULT_TTL_MS= CRANK_ENVIRONMENT=production -CRANK_LOG_LEVEL=info +CRANK_LOG_LEVEL= CRANK_SENTRY_DSN= CRANK_METRICS_ENABLED=true -CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464 -CRANK_MCP_METRICS_BIND=127.0.0.1:9465 CRANK_METRICS_BEARER_TOKEN= -CRANK_INVOCATION_LOG_RETENTION_DAYS=30 OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf @@ -50,12 +41,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 -CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local -CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password +CRANK_TRUST_FORWARDED_HEADERS=true +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 diff --git a/deploy/community/.env.images.example b/deploy/community/.env.images.example index b430ef3..dd8eada 100644 --- a/deploy/community/.env.images.example +++ b/deploy/community/.env.images.example @@ -1,34 +1,35 @@ +# Deployment-only image, project and publication settings. COMPOSE_PROJECT_NAME=crank - -POSTGRES_DB=crank -POSTGRES_USER=crank -POSTGRES_PASSWORD=change-me -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 POSTGRES_PUBLISH_BIND=127.0.0.1 - CRANK_ADMIN_API_IMAGE=git.itexp.me/bsodfather/crank-community-admin-api:main CRANK_MCP_SERVER_IMAGE=git.itexp.me/bsodfather/crank-community-mcp-server:main CRANK_UI_IMAGE=git.itexp.me/bsodfather/crank-community-ui:main - -CRANK_STORAGE_ROOT=/var/lib/crank/storage CRANK_PUBLISH_BIND=127.0.0.1 -CRANK_ADMIN_BIND=0.0.0.0:3001 -CRANK_MCP_BIND=0.0.0.0:3002 -CRANK_MCP_REFRESH_MS=5000 + +# 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_MASTER_KEY= +CRANK_BASE_URL=http://localhost:3000 +CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64 +CRANK_CACHE_BACKEND=memory +CRANK_CACHE_URL= CRANK_OUTBOUND_ALLOWED_HOSTS= CRANK_OUTBOUND_DENIED_HOSTS= CRANK_OUTBOUND_MAX_RESPONSE_BYTES=4194304 - -CRANK_CACHE_BACKEND=memory -CRANK_CACHE_URL= -CRANK_CACHE_DEFAULT_TTL_MS= - CRANK_ENVIRONMENT=production -CRANK_LOG_LEVEL=info +CRANK_LOG_LEVEL= +CRANK_SENTRY_DSN= CRANK_METRICS_ENABLED=true -CRANK_ADMIN_METRICS_BIND=127.0.0.1:9464 -CRANK_MCP_METRICS_BIND=127.0.0.1:9465 CRANK_METRICS_BEARER_TOKEN= OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= @@ -42,16 +43,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. Keep enabled only -# when admin-api runs behind the bundled nginx (or another trusted reverse -# proxy). Set to false if you expose admin-api directly to untrusted clients. CRANK_TRUST_FORWARDED_HEADERS=true -CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local -CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password +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 diff --git a/deploy/community/docker-compose.images.yml b/deploy/community/docker-compose.images.yml index 420f92d..bcd5d69 100644 --- a/deploy/community/docker-compose.images.yml +++ b/deploy/community/docker-compose.images.yml @@ -34,9 +34,10 @@ services: timeout: 5s retries: 5 - admin-api: + migrate: image: ${CRANK_ADMIN_API_IMAGE:-git.itexp.me/bsodfather/crank-community-admin-api:main} - restart: unless-stopped + command: ["crank-migrate", "apply"] + restart: "no" depends_on: postgres: condition: service_healthy @@ -47,6 +48,29 @@ services: POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} + POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} + POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} + POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} + POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} + + admin-api: + image: ${CRANK_ADMIN_API_IMAGE:-git.itexp.me/bsodfather/crank-community-admin-api:main} + restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + postgres: + condition: service_healthy + required: false + environment: + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-crank} + POSTGRES_USER: ${POSTGRES_USER:-crank} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} @@ -56,12 +80,11 @@ services: CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001} CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} - CRANK_CACHE_DEFAULT_TTL_MS: ${CRANK_CACHE_DEFAULT_TTL_MS:-} CRANK_ADMIN_RATE_LIMIT_RPS: ${CRANK_ADMIN_RATE_LIMIT_RPS:-30} CRANK_ADMIN_RATE_LIMIT_BURST: ${CRANK_ADMIN_RATE_LIMIT_BURST:-60} CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-production} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} CRANK_ADMIN_METRICS_BIND: ${CRANK_ADMIN_METRICS_BIND:-127.0.0.1:9464} @@ -106,6 +129,8 @@ services: image: ${CRANK_MCP_SERVER_IMAGE:-git.itexp.me/bsodfather/crank-community-mcp-server:main} restart: unless-stopped depends_on: + migrate: + condition: service_completed_successfully postgres: condition: service_healthy required: false @@ -115,23 +140,22 @@ services: POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} - CRANK_STORAGE_ROOT: ${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} CRANK_MCP_BIND: ${CRANK_MCP_BIND:-0.0.0.0:3002} CRANK_MCP_REFRESH_MS: ${CRANK_MCP_REFRESH_MS:-5000} CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} - CRANK_CACHE_DEFAULT_TTL_MS: ${CRANK_CACHE_DEFAULT_TTL_MS:-} CRANK_MCP_RATE_LIMIT_RPS: ${CRANK_MCP_RATE_LIMIT_RPS:-60} CRANK_MCP_RATE_LIMIT_BURST: ${CRANK_MCP_RATE_LIMIT_BURST:-120} CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS: ${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-16} CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-production} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} CRANK_MCP_METRICS_BIND: ${CRANK_MCP_METRICS_BIND:-127.0.0.1:9465} @@ -153,8 +177,6 @@ services: CRANK_OUTBOUND_ALLOWED_HOSTS: ${CRANK_OUTBOUND_ALLOWED_HOSTS:-} CRANK_OUTBOUND_DENIED_HOSTS: ${CRANK_OUTBOUND_DENIED_HOSTS:-} CRANK_OUTBOUND_MAX_RESPONSE_BYTES: ${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-4194304} - volumes: - - artifact_storage:${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002" healthcheck: diff --git a/deploy/community/docker-compose.yml b/deploy/community/docker-compose.yml index c3a85f2..cb452ac 100644 --- a/deploy/community/docker-compose.yml +++ b/deploy/community/docker-compose.yml @@ -1,4 +1,21 @@ services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-crank} + POSTGRES_USER: ${POSTGRES_USER:-crank} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "${POSTGRES_PUBLISH_BIND:-127.0.0.1}:${POSTGRES_PUBLISH_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crank} -d ${POSTGRES_DB:-crank}"] + interval: 10s + timeout: 5s + retries: 5 + valkey: image: valkey/valkey:8-alpine restart: unless-stopped @@ -15,18 +32,45 @@ services: timeout: 5s retries: 5 + migrate: + image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev} + build: + context: ../.. + dockerfile: apps/admin-api/Dockerfile + command: ["crank-migrate", "apply"] + restart: "no" + depends_on: + postgres: + condition: service_healthy + environment: + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-crank} + POSTGRES_USER: ${POSTGRES_USER:-crank} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} + POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} + POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} + POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} + POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} + admin-api: image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev} build: context: ../.. dockerfile: apps/admin-api/Dockerfile restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully environment: - POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} @@ -36,12 +80,11 @@ services: CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001} CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} - CRANK_CACHE_DEFAULT_TTL_MS: ${CRANK_CACHE_DEFAULT_TTL_MS:-} CRANK_ADMIN_RATE_LIMIT_RPS: ${CRANK_ADMIN_RATE_LIMIT_RPS:-30} CRANK_ADMIN_RATE_LIMIT_BURST: ${CRANK_ADMIN_RATE_LIMIT_BURST:-60} CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-production} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} CRANK_ADMIN_METRICS_BIND: ${CRANK_ADMIN_METRICS_BIND:-127.0.0.1:9464} @@ -88,29 +131,31 @@ services: context: ../.. dockerfile: apps/mcp-server/Dockerfile restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully environment: - POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} - CRANK_STORAGE_ROOT: ${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} CRANK_MCP_BIND: ${CRANK_MCP_BIND:-0.0.0.0:3002} CRANK_MCP_REFRESH_MS: ${CRANK_MCP_REFRESH_MS:-5000} CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} - CRANK_CACHE_DEFAULT_TTL_MS: ${CRANK_CACHE_DEFAULT_TTL_MS:-} CRANK_MCP_RATE_LIMIT_RPS: ${CRANK_MCP_RATE_LIMIT_RPS:-60} CRANK_MCP_RATE_LIMIT_BURST: ${CRANK_MCP_RATE_LIMIT_BURST:-120} CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS: ${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-16} CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-production} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} CRANK_MCP_METRICS_BIND: ${CRANK_MCP_METRICS_BIND:-127.0.0.1:9465} @@ -132,8 +177,6 @@ services: CRANK_OUTBOUND_ALLOWED_HOSTS: ${CRANK_OUTBOUND_ALLOWED_HOSTS:-} CRANK_OUTBOUND_DENIED_HOSTS: ${CRANK_OUTBOUND_DENIED_HOSTS:-} CRANK_OUTBOUND_MAX_RESPONSE_BYTES: ${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-4194304} - volumes: - - artifact_storage:${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002" healthcheck: @@ -162,5 +205,6 @@ services: retries: 5 volumes: + postgres_data: artifact_storage: valkey_data: diff --git a/docker-compose.yml b/docker-compose.yml index 6597d08..4c6a2d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,29 @@ services: timeout: 5s retries: 5 + migrate: + image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev} + build: + context: . + dockerfile: apps/admin-api/Dockerfile + command: ["crank-migrate", "apply"] + restart: "no" + environment: + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-crank} + POSTGRES_USER: ${POSTGRES_USER:-crank} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} + POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} + POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} + POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} + POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} + depends_on: + postgres: + condition: service_healthy + admin-api: image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev} build: @@ -28,9 +51,38 @@ services: POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} + POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} + POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} + POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} + POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} CRANK_STORAGE_ROOT: ${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_ADMIN_RATE_LIMIT_RPS: ${CRANK_ADMIN_RATE_LIMIT_RPS:-30} + CRANK_ADMIN_RATE_LIMIT_BURST: ${CRANK_ADMIN_RATE_LIMIT_BURST:-60} + CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} + CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} + CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} + CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-development} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} + CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} + CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} + CRANK_ADMIN_METRICS_BIND: ${CRANK_ADMIN_METRICS_BIND:-127.0.0.1:9464} + CRANK_METRICS_BEARER_TOKEN: ${CRANK_METRICS_BEARER_TOKEN:-} + CRANK_INVOCATION_LOG_RETENTION_DAYS: ${CRANK_INVOCATION_LOG_RETENTION_DAYS:-30} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: ${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-} + OTEL_EXPORTER_OTLP_TIMEOUT: ${OTEL_EXPORTER_OTLP_TIMEOUT:-10000} + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: ${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-} + OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-} + OTEL_BSP_MAX_QUEUE_SIZE: ${OTEL_BSP_MAX_QUEUE_SIZE:-2048} + OTEL_BSP_MAX_EXPORT_BATCH_SIZE: ${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-512} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-5000} + OTEL_BSP_EXPORT_TIMEOUT: ${OTEL_BSP_EXPORT_TIMEOUT:-30000} CRANK_MASTER_KEY: ${CRANK_MASTER_KEY} CRANK_BASE_URL: ${CRANK_BASE_URL:-http://localhost:3000} CRANK_SESSION_SECRET: ${CRANK_SESSION_SECRET} @@ -45,6 +97,8 @@ services: CRANK_OUTBOUND_DENIED_HOSTS: ${CRANK_OUTBOUND_DENIED_HOSTS:-} CRANK_OUTBOUND_MAX_RESPONSE_BYTES: ${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-4194304} depends_on: + migrate: + condition: service_completed_successfully postgres: condition: service_healthy volumes: @@ -69,20 +123,48 @@ services: POSTGRES_DB: ${POSTGRES_DB:-crank} POSTGRES_USER: ${POSTGRES_USER:-crank} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crank} - CRANK_STORAGE_ROOT: ${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} + CRANK_DATABASE_URL: ${CRANK_DATABASE_URL:-} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-20} + POSTGRES_MIN_CONNECTIONS: ${POSTGRES_MIN_CONNECTIONS:-2} + POSTGRES_ACQUIRE_TIMEOUT_MS: ${POSTGRES_ACQUIRE_TIMEOUT_MS:-5000} + POSTGRES_IDLE_TIMEOUT_MS: ${POSTGRES_IDLE_TIMEOUT_MS:-600000} + POSTGRES_MAX_LIFETIME_MS: ${POSTGRES_MAX_LIFETIME_MS:-1800000} CRANK_MCP_BIND: ${CRANK_MCP_BIND:-0.0.0.0:3002} CRANK_MCP_REFRESH_MS: ${CRANK_MCP_REFRESH_MS:-5000} - CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} + CRANK_MCP_RATE_LIMIT_RPS: ${CRANK_MCP_RATE_LIMIT_RPS:-60} + CRANK_MCP_RATE_LIMIT_BURST: ${CRANK_MCP_RATE_LIMIT_BURST:-120} + CRANK_RUNTIME_MAX_CONCURRENT_UNARY: ${CRANK_RUNTIME_MAX_CONCURRENT_UNARY:-64} + CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS: ${CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS:-16} + CRANK_CACHE_BACKEND: ${CRANK_CACHE_BACKEND:-memory} + CRANK_CACHE_URL: ${CRANK_CACHE_URL:-} + CRANK_ENVIRONMENT: ${CRANK_ENVIRONMENT:-development} + CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-} + CRANK_SENTRY_DSN: ${CRANK_SENTRY_DSN:-} + CRANK_METRICS_ENABLED: ${CRANK_METRICS_ENABLED:-true} + CRANK_MCP_METRICS_BIND: ${CRANK_MCP_METRICS_BIND:-127.0.0.1:9465} + CRANK_METRICS_BEARER_TOKEN: ${CRANK_METRICS_BEARER_TOKEN:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: ${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-} + OTEL_EXPORTER_OTLP_TIMEOUT: ${OTEL_EXPORTER_OTLP_TIMEOUT:-10000} + OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: ${OTEL_EXPORTER_OTLP_TRACES_TIMEOUT:-} + OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${OTEL_EXPORTER_OTLP_TRACES_HEADERS:-} + OTEL_BSP_MAX_QUEUE_SIZE: ${OTEL_BSP_MAX_QUEUE_SIZE:-2048} + OTEL_BSP_MAX_EXPORT_BATCH_SIZE: ${OTEL_BSP_MAX_EXPORT_BATCH_SIZE:-512} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-5000} + OTEL_BSP_EXPORT_TIMEOUT: ${OTEL_BSP_EXPORT_TIMEOUT:-30000} CRANK_MASTER_KEY: ${CRANK_MASTER_KEY} CRANK_BASE_URL: ${CRANK_BASE_URL:-http://localhost:3000} CRANK_OUTBOUND_ALLOWED_HOSTS: ${CRANK_OUTBOUND_ALLOWED_HOSTS:-} CRANK_OUTBOUND_DENIED_HOSTS: ${CRANK_OUTBOUND_DENIED_HOSTS:-} CRANK_OUTBOUND_MAX_RESPONSE_BYTES: ${CRANK_OUTBOUND_MAX_RESPONSE_BYTES:-4194304} depends_on: + migrate: + condition: service_completed_successfully postgres: condition: service_healthy - volumes: - - artifact_storage:${CRANK_STORAGE_ROOT:-/var/lib/crank/storage} ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:3002:3002" healthcheck: diff --git a/docs/README.md b/docs/README.md index bbefc66..b2eadc0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,18 @@ Crank превращает REST API endpoint-ы в MCP-инструменты, ## Справочник +- [Capability Inventory](./capability-inventory.json) — machine-readable реестр + текущих и целевых flows; canonical path — `docs/capability-inventory.json`. +- [Schema Capability Inventory](./schemas/capability-inventory.schema.json) +- [Capability Baseline](./capability-baseline/manifest.json) — versioned SHA-256 + snapshot фактических UI, Admin API и MCP flows, taxonomy, checklist и + sanitized evidence results. +- [Schema Capability Baseline](./schemas/capability-baseline.schema.json) — + fail-closed manifest contract. - [Настройки окружения](./runtime-config.md) +- [Machine schema runtime-конфигурации](./schemas/runtime-config.schema.json) +- [Миграции PostgreSQL](./migrations.md) +- [Machine sequence миграций](./schemas/migration-sequence.json) - [Admin API](./admin-api.md) - [Развертывание](./deployment.md) - [Production checklist](./production-checklist.md) @@ -31,3 +42,12 @@ Crank превращает REST API endpoint-ы в MCP-инструменты, - [Архитектура](./architecture.md) - [Модель данных](./data-model.md) - [Тестирование](./testing-strategy.md) + +В Inventory только статус `implemented` означает готовность. Статусы `planned`, +`gap` и `blocked` остаются непроходными и не являются заявлением о наличии +функции в текущей Community-версии. + +Baseline разделяет implementation status, execution verdict и evidence mode. +Полный pass — только `implemented + automated + pass`; `flaky`, `skipped`, +`not_run` и `manual_only` остаются non-pass. После изменения snapshot должны +обновиться `baseline_version` и manifest checksums. diff --git a/docs/capability-baseline/manifest.json b/docs/capability-baseline/manifest.json new file mode 100644 index 0000000..f95fcf7 --- /dev/null +++ b/docs/capability-baseline/manifest.json @@ -0,0 +1,31 @@ +{ + "artifacts": [ + { + "kind": "inventory", + "path": "docs/capability-inventory.json", + "sha256": "fa6728e211dc55bf49225884a6cf0182ecb2e3bc1dc59e4580499e551ae4dcf1" + }, + { + "kind": "required_surfaces", + "path": "docs/capability-baseline/required-surfaces.json", + "sha256": "61714a2a141cf0a657816f35a6bfe31a7f2de77296303343bfaf6aeab9802681" + }, + { + "kind": "taxonomy", + "path": "docs/capability-baseline/outcome-taxonomy.json", + "sha256": "2a57ef358ea08ad436bd96244cd7000a660e90b4ecd04a27d5fd071ea394e549" + }, + { + "kind": "checklist", + "path": "docs/capability-baseline/manual-checklist.md", + "sha256": "688cafba282bac5a781f6b90f1a305d2ec30d1bf11b5a1f1af1f20fed3c26798" + }, + { + "kind": "results", + "path": "docs/capability-baseline/results.json", + "sha256": "aa127c8e5aabed6bdc4fdcb4734da7ea1c4b2c82e42ff47c603737e2ffa38e58" + } + ], + "baseline_version": "2026.08.10.1", + "schema_version": 1 +} diff --git a/docs/capability-baseline/manual-checklist.md b/docs/capability-baseline/manual-checklist.md new file mode 100644 index 0000000..b126eda --- /dev/null +++ b/docs/capability-baseline/manual-checklist.md @@ -0,0 +1,89 @@ +# Community UI capability baseline checklist + +baseline_version: 2026.08.10.1 + +This is a bounded brownfield baseline, not the release-candidate regression from Epic 8. For every charter record happy, loading, empty, error, recovery, stale-response, RU/EN, and safe-output observations. Use `not_run` for an applicable unexecuted state, `gap` for a missing required contract, and `n/a` only with a by-design reason. + +## UI-01 Authentication and workspace + +- flow_id: ui-auth-workspace +- states: happy, loading, error, recovery, stale, ru-en, safe-output +- verdict: pass +- reason: Wrong-password error and successful recovery were observed; settings and RU/EN navigation remained usable. + +## UI-02 Operations catalog and lifecycle + +- flow_id: ui-operation-lifecycle +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Happy, loading, empty, error and recovery rendered, but an older response overwrote newer state and a credential-shaped error canary reached the DOM; see DEF-UI-001 and DEF-UI-003. + +## UI-03 Operation test execution + +- flow_id: ui-operation-test +- states: happy, loading, error, recovery, stale, ru-en, safe-output +- verdict: pass +- reason: Wizard happy path, validation feedback and recovery controls were observed; loading and empty are not applicable to this local step flow. + +## UI-04 YAML and OpenAPI import/export + +- flow_id: ui-operation-import-export +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Import entry and recovery are present, but EN mode still exposes Russian-only OpenAPI text; see DEF-UI-002. + +## UI-05 Agent lifecycle and bindings + +- flow_id: ui-agent-management +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Happy, error and recovery rendered, but stale-response protection is absent and a credential-shaped error canary reached the DOM; see DEF-UI-001 and DEF-UI-003. + +## UI-06 MCP and approval keys + +- flow_id: ui-mcp-approval-keys +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: pass +- reason: Agent selection, key creation entry and single raw-key disclosure guidance were observed; the seeded stack made the initial empty state inapplicable. + +## UI-07 Auth Profiles, Upstreams, and Secrets + +- flow_id: ui-auth-profiles-upstreams-secrets +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Happy, real empty, error and recovery rendered, but a credential-shaped error canary reached the DOM; see DEF-UI-003. + +## UI-08 Logs and approvals + +- flow_id: ui-logs-approvals +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Happy, error and recovery rendered, but a credential-shaped error canary reached the DOM; see DEF-UI-003. + +## UI-09 Usage + +- flow_id: ui-usage +- states: happy, loading, empty, error, recovery, stale, ru-en, safe-output +- verdict: fail +- reason: Happy, error and recovery rendered, but a credential-shaped error canary reached the DOM; see DEF-UI-003. + +## UI-10 Cross-screen stale response + +- flow_id: ui-auth-workspace +- states: stale, recovery +- verdict: fail +- reason: A delayed older Operations response overwrote a newer empty response; see DEF-UI-001. + +## UI-11 Localization consistency + +- flow_id: ui-operation-lifecycle +- states: ru-en +- verdict: fail +- reason: Switching to EN left RU fragments on the Operations screen and OpenAPI import has hardcoded RU text; see DEF-UI-002. + +## UI-12 Unsafe output canaries + +- flow_id: ui-logs-approvals +- states: safe-output, error +- verdict: fail +- reason: Credential-shaped API error canaries were visible in multiple browser error states; see DEF-UI-003. diff --git a/docs/capability-baseline/outcome-taxonomy.json b/docs/capability-baseline/outcome-taxonomy.json new file mode 100644 index 0000000..9195e33 --- /dev/null +++ b/docs/capability-baseline/outcome-taxonomy.json @@ -0,0 +1,28 @@ +{ + "baseline_version": "2026.08.10.1", + "evidence_modes": [ + "automated", + "manual_only" + ], + "execution_verdicts": [ + "pass", + "fail", + "blocked", + "skipped", + "flaky", + "not_run" + ], + "full_pass": { + "evidence_mode": "automated", + "execution_verdict": "pass", + "implementation_status": "implemented" + }, + "implementation_statuses": [ + "implemented", + "planned", + "gap", + "blocked" + ], + "manual_only_rule": "Requires a retained manual result and next_evidence; it is not a full baseline pass.", + "non_pass_rule": "Failed, blocked, skipped, flaky, not_run, missing, or malformed evidence never becomes pass." +} diff --git a/docs/capability-baseline/required-surfaces.json b/docs/capability-baseline/required-surfaces.json new file mode 100644 index 0000000..cbfbf16 --- /dev/null +++ b/docs/capability-baseline/required-surfaces.json @@ -0,0 +1,95 @@ +{ + "baseline_version": "2026.08.10.1", + "required_flow_ids": [ + "api-agent-catalog", + "api-approvals", + "api-auth-profiles-upstreams-secrets", + "api-auth-workspace", + "api-canonical-request-trace-identity", + "api-logs-usage", + "api-mcp-approval-keys", + "api-operation-import-export", + "api-operation-lifecycle", + "api-operation-test-run", + "mcp-approval-lifecycle", + "mcp-published-tool-call", + "mcp-published-tool-list", + "mcp-scoped-tool-search", + "mcp-transport-session", + "ui-agent-management", + "ui-auth-profiles-upstreams-secrets", + "ui-auth-workspace", + "ui-logs-approvals", + "ui-mcp-approval-keys", + "ui-operation-import-export", + "ui-operation-lifecycle", + "ui-operation-test", + "ui-usage" + ], + "surface_groups": [ + { + "flow_ids": [ + "api-canonical-request-trace-identity" + ], + "id": "correlation" + }, + { + "flow_ids": [ + "ui-operation-lifecycle", + "ui-operation-test", + "ui-operation-import-export", + "api-operation-lifecycle", + "api-operation-test-run", + "api-operation-import-export" + ], + "id": "operations" + }, + { + "flow_ids": [ + "ui-agent-management", + "api-agent-catalog" + ], + "id": "agents" + }, + { + "flow_ids": [ + "ui-auth-profiles-upstreams-secrets", + "api-auth-profiles-upstreams-secrets" + ], + "id": "credentials" + }, + { + "flow_ids": [ + "ui-mcp-approval-keys", + "api-mcp-approval-keys", + "mcp-approval-lifecycle" + ], + "id": "keys-and-approvals" + }, + { + "flow_ids": [ + "ui-logs-approvals", + "ui-usage", + "api-approvals", + "api-logs-usage" + ], + "id": "history-and-usage" + }, + { + "flow_ids": [ + "ui-auth-workspace", + "api-auth-workspace" + ], + "id": "auth-and-workspace" + }, + { + "flow_ids": [ + "mcp-transport-session", + "mcp-published-tool-list", + "mcp-published-tool-call", + "mcp-scoped-tool-search" + ], + "id": "mcp-tools" + } + ] +} diff --git a/docs/capability-baseline/results.json b/docs/capability-baseline/results.json new file mode 100644 index 0000000..545a028 --- /dev/null +++ b/docs/capability-baseline/results.json @@ -0,0 +1,318 @@ +{ + "baseline_version": "2026.08.10.1", + "defects": [ + { + "contract": "Local Playwright stack starts only after the configured database exists and is stable.", + "flow_ids": ["ui-auth-workspace"], + "id": "DEF-BL-001", + "next_action": "Make readiness wait for the configured database after PostgreSQL bootstrap completes.", + "owner": "quality-community", + "severity": "Medium", + "steps": [ + "Start the default Playwright stack with a new local PostgreSQL container.", + "Observe pg_isready succeed against the temporary bootstrap server.", + "Observe Admin API lose its connection when PostgreSQL restarts after database creation." + ] + }, + { + "contract": "Stopping the local Playwright stack terminates its loop and all child services.", + "flow_ids": ["ui-auth-workspace"], + "id": "DEF-BL-002", + "next_action": "Exit after the signal cleanup trap and terminate the actual cargo child processes.", + "owner": "quality-community", + "severity": "Medium", + "steps": [ + "Start the Playwright stack and wait for readiness.", + "Send an interrupt to the stack process.", + "Observe the loop or re-parented Admin API and MCP processes remain alive." + ] + }, + { + "contract": "Workspace-dependent asynchronous UI loads reject stale responses.", + "flow_ids": [ + "ui-agent-management", + "ui-auth-profiles-upstreams-secrets", + "ui-logs-approvals", + "ui-mcp-approval-keys", + "ui-operation-lifecycle", + "ui-usage" + ], + "id": "DEF-UI-001", + "next_action": "Add request-generation identity guards in the owning UI hardening story and automate reordered responses.", + "owner": "admin-ui-community", + "severity": "Medium", + "steps": [ + "Inspect workspace-change loaders for Operations, Agents, keys, secrets, logs, and usage.", + "Confirm requests are restarted on workspace change.", + "Confirm no shared request-generation or abort guard prevents an older response from overwriting newer state." + ] + }, + { + "contract": "OpenAPI import user-visible text follows the selected RU or EN locale.", + "flow_ids": ["ui-operation-import-export"], + "id": "DEF-UI-002", + "next_action": "Move OpenAPI import strings into the existing RU and EN i18n contract.", + "owner": "import-ui-community", + "severity": "Medium", + "steps": [ + "Select the EN locale.", + "Open the OpenAPI import flow.", + "Observe Russian-only labels and status messages in the import component." + ] + }, + { + "contract": "Browser-visible error states never render credential-shaped fields received from an untrusted API response.", + "flow_ids": [ + "ui-agent-management", + "ui-auth-profiles-upstreams-secrets", + "ui-logs-approvals", + "ui-operation-lifecycle", + "ui-usage" + ], + "id": "DEF-UI-003", + "next_action": "Normalize browser API errors through a safe allowlisted message before rendering and add canary regressions for every async screen.", + "owner": "admin-ui-community", + "severity": "High", + "steps": [ + "Authenticate to the local Community UI with seeded test data.", + "Return a bounded credential-shaped canary in a 500 response for each affected list endpoint.", + "Observe the untrusted response message rendered in the corresponding browser error state." + ] + } + ], + "environment_class": "community-test", + "manual_results": [ + { + "check_id": "UI-01", + "evidence_mode": "manual_only", + "execution_verdict": "pass", + "flow_ids": ["ui-auth-workspace"], + "next_evidence": "Automate the observed login error, recovery and locale transitions." + }, + { + "check_id": "UI-02", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-operation-lifecycle"], + "next_evidence": "Retest reordered responses and safe error rendering after DEF-UI-001 and DEF-UI-003 are fixed." + }, + { + "check_id": "UI-03", + "evidence_mode": "manual_only", + "execution_verdict": "pass", + "flow_ids": ["ui-operation-test"], + "next_evidence": "Automate the observed wizard validation and recovery states." + }, + { + "check_id": "UI-04", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-operation-import-export"], + "next_evidence": "Retest RU/EN after DEF-UI-002 and exercise malformed import recovery." + }, + { + "check_id": "UI-05", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-agent-management"], + "next_evidence": "Retest reordered responses and safe error rendering after DEF-UI-001 and DEF-UI-003 are fixed." + }, + { + "check_id": "UI-06", + "evidence_mode": "manual_only", + "execution_verdict": "pass", + "flow_ids": ["ui-mcp-approval-keys"], + "next_evidence": "Automate the observed empty/key-guidance state and a bounded create-and-cleanup reveal pass." + }, + { + "check_id": "UI-07", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-auth-profiles-upstreams-secrets"], + "next_evidence": "Retest safe error rendering after DEF-UI-003 is fixed." + }, + { + "check_id": "UI-08", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-logs-approvals"], + "next_evidence": "Retest safe error rendering after DEF-UI-003 is fixed." + }, + { + "check_id": "UI-09", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-usage"], + "next_evidence": "Retest safe errors and reordered responses after DEF-UI-001 and DEF-UI-003 are fixed." + }, + { + "check_id": "UI-10", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-auth-workspace"], + "next_evidence": "Add deterministic reordered-response tests after DEF-UI-001 is addressed." + }, + { + "check_id": "UI-11", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-operation-lifecycle"], + "next_evidence": "Repeat the EN locale pass after DEF-UI-002 is addressed." + }, + { + "check_id": "UI-12", + "evidence_mode": "manual_only", + "execution_verdict": "fail", + "flow_ids": ["ui-logs-approvals"], + "next_evidence": "Repeat the bounded canary pass after DEF-UI-003 is fixed." + } + ], + "runs": [ + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "rust-admin-integration", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "api-agent-catalog", + "api-approvals", + "api-auth-profiles-upstreams-secrets", + "api-auth-workspace", + "api-logs-usage", + "api-mcp-approval-keys", + "api-operation-import-export", + "api-operation-lifecycle", + "api-operation-test-run" + ], + "id": "run-rust-admin-integration-fa07285c8e58", + "environment_class": "community-test", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "source_report_sha256": "fa07285c8e58dfad3834ffd420d80c91cdf0f0b5d0c54ef3e0eeaf35df3ab3f8" + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "rust-mcp-integration", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "mcp-approval-lifecycle", + "mcp-published-tool-call", + "mcp-published-tool-list", + "mcp-scoped-tool-search", + "mcp-transport-session" + ], + "id": "run-rust-mcp-integration-ccff7c0029c1", + "environment_class": "community-test", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "source_report_sha256": "ccff7c0029c1347459a76b8ecd63a308a7162ddb5bc272441e79ffbf2ccdfb83" + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "ui-build", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "ui-auth-workspace", + "ui-mcp-approval-keys", + "ui-operation-import-export", + "ui-operation-test" + ], + "id": "run-ui-build-755e91f2200b", + "environment_class": "community-test", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "source_report_sha256": "755e91f2200b4ac43783820afde4f5d6b336ff7a6ebd5e71b055c43d5c2e4986" + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "ui-playwright", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "ui-auth-workspace", + "ui-mcp-approval-keys", + "ui-operation-import-export", + "ui-operation-test" + ], + "id": "run-ui-playwright-a1236d5f45ab", + "environment_class": "community-test", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "summary": {"passed": 21, "failed": 0, "flaky": 0, "skipped": 0}, + "source_report_sha256": "a1236d5f45ab6273625bdb61634b1ea6c6d3ac876757b85a0a5ed8b5855fea6b" + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "authenticated-product-smoke", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "api-agent-catalog", + "api-mcp-approval-keys", + "api-operation-lifecycle", + "api-operation-test-run", + "mcp-published-tool-call", + "mcp-published-tool-list", + "mcp-transport-session" + ], + "id": "run-authenticated-product-smoke-471a29ea400f", + "environment_class": "community-test", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "safe_outcome": { + "agent_id": "agent_019fe3a5468d75039020e367f1096136", + "agent_revision": 1, + "operation_id": "op_019fe3a546177533a2df26a36c79d358", + "operation_version": 1, + "stages": ["admin_test", "operation_publish", "agent_publish", "mcp_list", "mcp_call"] + }, + "source_report_sha256": "471a29ea400fe1cb3eceb68d073c15ecba4e95ee5dff487fec0bb969f09c65e0" + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "just-verify", + "environment_class": "community-test", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "api-agent-catalog", + "api-approvals", + "api-auth-profiles-upstreams-secrets", + "api-auth-workspace", + "api-logs-usage", + "api-mcp-approval-keys", + "api-operation-import-export", + "api-operation-lifecycle", + "api-operation-test-run", + "mcp-approval-lifecycle", + "mcp-published-tool-call", + "mcp-published-tool-list", + "mcp-scoped-tool-search", + "mcp-transport-session" + ], + "id": "run-just-verify-c6c16ce3c4b2", + "source_report_sha256": "c6c16ce3c4b2fa8355ae73c6ad6e87f3c829e86d720cd153133c161d073330b2", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "summary": {"exit_code": 0, "skipped": 0, "timed_out": false} + }, + { + "accepted": true, + "collector": "capability-baseline-collector-v1", + "command_id": "just-verify", + "environment_class": "community-test", + "evidence_mode": "automated", + "execution_verdict": "pass", + "flow_ids": [ + "api-canonical-request-trace-identity" + ], + "id": "run-just-verify-a3e1538374ee", + "source_report_sha256": "a3e1538374eea02b3140ea20a7966c01225a78ac510296d0bb08eaf87a3c476c", + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006", + "summary": {"exit_code": 0, "skipped": 0, "timed_out": false} + } + ], + "source_revision": "c30461cc92491ebe3d527e125a1657b73fa67006" +} diff --git a/docs/capability-inventory.json b/docs/capability-inventory.json new file mode 100644 index 0000000..2ace868 --- /dev/null +++ b/docs/capability-inventory.json @@ -0,0 +1,316 @@ +{ + "flows": [ + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/login.spec.js", "apps/ui/tests/e2e/workspace-settings.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-auth-workspace", + "owner": "admin-ui-community", + "requirements": ["FR-3", "FR-46"], + "status": "implemented", + "type": "ui", + "user_outcome": "An administrator can authenticate and work in the selected Community workspace." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/operations.spec.js", "apps/ui/tests/e2e/wizard.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-operation-lifecycle", + "owner": "operations-ui-community", + "requirements": ["FR-1", "FR-46"], + "status": "blocked", + "type": "ui", + "user_outcome": "An administrator can create, edit, publish, archive, and inspect REST Operations." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/wizard.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-operation-test", + "owner": "runtime-ui-community", + "requirements": ["FR-2", "FR-46"], + "status": "implemented", + "type": "ui", + "user_outcome": "An administrator can execute a bounded Operation test and inspect its safe outcome." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/operations.spec.js", "apps/ui/tests/e2e/wizard.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-operation-import-export", + "owner": "import-ui-community", + "requirements": ["FR-1", "FR-46"], + "status": "implemented", + "type": "ui", + "user_outcome": "An administrator can import OpenAPI or YAML drafts and export an Operation configuration." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/agents.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-agent-management", + "owner": "agents-ui-community", + "requirements": ["FR-4", "FR-46"], + "status": "blocked", + "type": "ui", + "user_outcome": "An administrator can manage an Agent and its published Operation bindings." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/api-keys.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-mcp-approval-keys", + "owner": "keys-ui-community", + "requirements": ["FR-3", "FR-5", "FR-46"], + "status": "implemented", + "type": "ui", + "user_outcome": "An administrator can create, reveal once, inspect, revoke, and delete MCP or approval keys." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/secrets.spec.js", "apps/ui/tests/e2e/wizard.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-auth-profiles-upstreams-secrets", + "owner": "credentials-ui-community", + "requirements": ["FR-3", "FR-46"], + "status": "blocked", + "type": "ui", + "user_outcome": "An administrator can configure reusable upstream, Auth Profile, and secret references without re-reading plaintext." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/logs-usage.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-logs-approvals", + "owner": "history-ui-community", + "requirements": ["FR-5", "FR-6", "FR-46"], + "status": "blocked", + "type": "ui", + "user_outcome": "An administrator can inspect invocation logs, safe details, and pending approvals." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/ui/tests/e2e/logs-usage.spec.js"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "ui-usage", + "owner": "usage-ui-community", + "requirements": ["FR-6", "FR-46"], + "status": "blocked", + "type": "ui", + "user_outcome": "An administrator can inspect workspace, Operation, and Agent usage summaries." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/auth_rate_limit.rs", "apps/admin-api/tests/integration/community_access_usage.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-auth-workspace", + "owner": "admin-api-community", + "requirements": ["FR-3", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Authenticated Admin API requests are scoped to the authorized Community workspace." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/operations_agents.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-operation-lifecycle", + "owner": "operations-api-community", + "requirements": ["FR-1", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can manage immutable published Operation versions and drafts." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/operations_agents.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-operation-test-run", + "owner": "runtime-api-community", + "requirements": ["FR-2", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can execute a REST Operation test through the current runtime boundary." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/openapi_import.rs", "apps/admin-api/tests/integration/secrets_import_auth.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-operation-import-export", + "owner": "import-api-community", + "requirements": ["FR-1", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can preview and create OpenAPI imports and round-trip YAML configuration." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/operations_agents.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-agent-catalog", + "owner": "agents-api-community", + "requirements": ["FR-4", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can manage Agent revisions, exact bindings, publication, and catalog search policy." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/community_access_usage.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-mcp-approval-keys", + "owner": "keys-api-community", + "requirements": ["FR-3", "FR-5", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can manage separately scoped MCP and approval keys with a single raw disclosure." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/secrets_import_auth.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-auth-profiles-upstreams-secrets", + "owner": "credentials-api-community", + "requirements": ["FR-3", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can manage upstreams, Auth Profiles, and encrypted secret references." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/community_access_usage.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-approvals", + "owner": "approvals-api-community", + "requirements": ["FR-5", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can list and inspect approval requests without leaking unsafe payloads." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/community_access_usage.rs", "apps/admin-api/tests/dc08.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "api-logs-usage", + "owner": "history-api-community", + "requirements": ["FR-6", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin API clients can query safe invocation history and usage summaries." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/mcp-server/tests/integration/transport_protocol.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "mcp-transport-session", + "owner": "mcp-transport-community", + "requirements": ["FR-3", "FR-4", "FR-46"], + "status": "implemented", + "type": "mcp", + "user_outcome": "An authorized MCP client can initialize and terminate a bounded Streamable HTTP session." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/mcp-server/tests/integration/transport_protocol.rs", "apps/mcp-server/tests/integration/catalog_access.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "mcp-published-tool-list", + "owner": "mcp-catalog-community", + "requirements": ["FR-4", "FR-46"], + "status": "implemented", + "type": "mcp", + "user_outcome": "An MCP client sees only bound, authorized, published Tool versions for its Agent." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/mcp-server/tests/integration/transport_protocol.rs", "apps/mcp-server/tests/integration/execution_stages.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "mcp-published-tool-call", + "owner": "mcp-runtime-community", + "requirements": ["FR-2", "FR-4", "FR-6", "FR-46"], + "status": "implemented", + "type": "mcp", + "user_outcome": "An MCP client can invoke an authorized published REST Tool and receive a safe structured outcome." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/mcp-server/tests/integration/tool_search.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "mcp-scoped-tool-search", + "owner": "mcp-catalog-community", + "requirements": ["FR-4", "FR-46"], + "status": "implemented", + "type": "mcp", + "user_outcome": "An MCP client can use Agent-scoped search meta-tools without escaping its published catalog." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/mcp-server/tests/integration/catalog_access/approval_access.rs"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + "id": "mcp-approval-lifecycle", + "owner": "mcp-approvals-community", + "requirements": ["FR-5", "FR-46"], + "status": "implemented", + "type": "mcp", + "user_outcome": "A separately authorized approval client can list and decide pending requests with bounded side effects." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["apps/admin-api/tests/integration/request_context.rs", "apps/mcp-server/tests/integration/request_context.rs", "apps/mcp-server/tests/integration/execution_stages.rs", "crates/crank-registry/tests/integration/migrations.rs"], "manual": ["docs/observability.md"]}, + "id": "api-canonical-request-trace-identity", + "owner": "observability-community", + "requirements": ["FR-27", "FR-46"], + "status": "implemented", + "type": "api", + "user_outcome": "Admin and MCP requests expose separate safe Request and Trace identities that survive runtime execution and history persistence without requiring telemetry export." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-production-foundation", + "owner": "platform-community", + "requirements": ["FR-7", "FR-8", "FR-9", "FR-10", "FR-11", "FR-12", "FR-13", "FR-14", "FR-28", "FR-29", "FR-30", "FR-31", "FR-32", "FR-33", "FR-34", "FR-35"], + "status": "planned", + "type": "api", + "user_outcome": "Community runtime, safety, correlation, and observability foundations reach their planned production contracts." + }, + { + "capabilities": ["resources"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-resource-read", + "owner": "mcp-community", + "requirements": ["FR-15", "FR-16", "FR-46"], + "status": "planned", + "type": "mcp", + "user_outcome": "MCP clients can discover and read published Resources." + }, + { + "capabilities": ["prompts"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-prompt-get", + "owner": "mcp-community", + "requirements": ["FR-17", "FR-18", "FR-19", "FR-46"], + "status": "planned", + "type": "mcp", + "user_outcome": "MCP clients can list and render published parameterized Prompts." + }, + { + "capabilities": ["tasks"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-background-task", + "owner": "execution-community", + "requirements": ["FR-20", "FR-21", "FR-22", "FR-23", "FR-24", "FR-25", "FR-26", "FR-46"], + "status": "planned", + "type": "mcp", + "user_outcome": "Users can observe and control durable background executions." + }, + { + "capabilities": ["load_runs"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-load-run", + "owner": "quality-community", + "requirements": ["FR-36", "FR-37", "FR-38", "FR-39", "FR-40", "FR-41", "FR-46"], + "status": "planned", + "type": "ui", + "user_outcome": "Administrators can run a bounded load scenario and inspect its quality statistics." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-community-deployment", + "owner": "release-community", + "requirements": ["FR-42", "FR-43", "FR-44", "FR-45", "FR-46"], + "status": "planned", + "type": "api", + "user_outcome": "Operators can deploy, upgrade, restore, and operate a production Community installation." + }, + { + "capabilities": ["tools"], + "evidence": {"automated": ["tests/unit/test_validate_capability_inventory.py"], "manual": ["docs/intro.md"]}, + "id": "planned-release-qualification", + "owner": "quality-community", + "requirements": ["FR-47", "FR-48", "FR-49", "FR-50", "FR-51", "FR-52", "FR-53", "FR-54"], + "status": "planned", + "type": "api", + "user_outcome": "A release candidate is qualified by immutable, reproducible technical and manual evidence." + } + ], + "product": "crank-community", + "schema_version": 1 +} diff --git a/docs/deployment.md b/docs/deployment.md index 4ccc503..6d42f69 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,11 +2,12 @@ Документ описывает поддерживаемый путь запуска Crank на сервере. -Crank запускается как три контейнера за reverse proxy: +Crank запускает три long-running контейнера за reverse proxy и один обязательный one-shot migration job: - `ui` - `admin-api` - `mcp-server` +- `migrate` (завершается до readiness Admin/MCP) Можно использовать внешний PostgreSQL или локальный PostgreSQL из compose-профиля `local-db`. @@ -18,8 +19,10 @@ reverse proxy /api/admin/ -> admin-api:3001 /mcp/ -> mcp-server:3002 -admin-api -> PostgreSQL -mcp-server -> PostgreSQL +migrate -> PostgreSQL -> exit 0 + | + +-> admin-api readiness + +-> mcp-server readiness admin-api -> Valkey/Redis, опционально mcp-server -> Valkey/Redis, опционально admin-api -> внешний OTLP endpoint, опционально @@ -105,7 +108,7 @@ docker compose \ config -q ``` -Запуск с внешним PostgreSQL: +Перед обновлением существующей установки выполните preflight и проверенный backup по [migration runbook](migrations.md). Source Compose по умолчанию поднимает локальный PostgreSQL 16. После безопасной последовательности запустите: ```bash docker compose \ @@ -143,7 +146,7 @@ cp .env.example .env docker compose --profile local-db up -d ``` -Если используется внешний PostgreSQL, заполните `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` и запустите: +Если используется внешний PostgreSQL, заполните `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, сначала выполните preflight и backup по [migration runbook](migrations.md), затем запустите: ```bash docker compose up -d @@ -182,9 +185,9 @@ curl -I http://127.0.0.1:3000/ CRANK_RESTORE_CONFIRM=restore ./scripts/restore-community.sh /opt/crank /opt/crank/backups/20260721T120000Z ``` -- Обновления схемы выполняются под блокировкой, одной транзакцией и фиксируются в `__crank_core_migrations`. Миграции Community должны оставаться обратно совместимыми с предыдущей версией приложения. +- Обновления схемы выполняет только one-shot `crank-migrate apply` под canonical transaction advisory lock. `admin-api` и `mcp-server` выполняют read-only compatibility check и не стартуют до успешного migration job. Canonical sequence фиксируется в `__crank_migrations`, legacy ledgers остаются readable; подробности — в [migrations.md](migrations.md). - Не храните реальные секреты в Git. -- CD использует неизменяемые теги коммитов и автоматически возвращает прежнюю конфигурацию и образы при провале readiness. +- CD использует неизменяемые теги коммитов. После schema migration автоматический возврат старых образов запрещён, пока N/N-1 window не квалифицирован Story 7.2: оператор сохраняет backup и выбирает matching forward image либо доказанное восстановление согласованного комплекта. - `CRANK_PUBLISH_BIND=0.0.0.0` нужен только если reverse proxy работает на другом host. - OTLP Collector и хранилище трасс не входят в Community Compose. Для внешнего приёмника задайте `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`; секретные diff --git a/docs/en/README.md b/docs/en/README.md index caa7f21..b26769e 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -27,6 +27,19 @@ This repository contains the Community version: - PostgreSQL database; - optional Valkey or Redis for temporary coordination state. +The executable Community boundary is recorded in +[`docs/capability-inventory.json`](../capability-inventory.json). Only +`implemented` denotes a delivered flow; `planned`, `gap`, and `blocked` do not +count as ready. Resources, Prompts, background Tasks, and Load Runs are planned +targets and are not advertised as implemented in the current version. + +The verified brownfield snapshot is recorded in +[`docs/capability-baseline/manifest.json`](../capability-baseline/manifest.json). +It binds the inventory, required surfaces, taxonomy, bounded manual checklist, +and sanitized results by exact SHA-256. A full pass requires +`implemented + automated + pass`; flaky, skipped, not-run, and manual-only +evidence remain non-pass. + ## Documentation The main documentation is currently maintained in Russian: @@ -38,6 +51,9 @@ The main documentation is currently maintained in Russian: - [MCP interface](../mcp-interface.md) - [Admin API](../admin-api.md) - [Runtime configuration](../runtime-config.md) +- [Runtime configuration machine schema](../schemas/runtime-config.schema.json) +- [PostgreSQL migration contract](migrations.md) ([full operator contract in Russian](../migrations.md)) +- [Migration machine sequence](../schemas/migration-sequence.json) - [Troubleshooting](../troubleshooting.md) ## License diff --git a/docs/en/migrations.md b/docs/en/migrations.md new file mode 100644 index 0000000..38ef195 --- /dev/null +++ b/docs/en/migrations.md @@ -0,0 +1,22 @@ +# PostgreSQL migrations + +Crank has one append-only migration authority in `crank-registry`. The Admin and MCP services run a read-only compatibility preflight; only the one-shot `crank-migrate` process may execute DDL. + +For an existing installation, always run: read-only `preflight`, verified PostgreSQL and artifact-storage backup, `plan --check`, controlled `apply`, then a final `preflight`. Exit code `2` from CLI preflight means migration is required; exit code `1` blocks mutation until the reported condition is resolved. + +Source command: + +```bash +cargo run -p admin-api --bin crank-migrate -- plan --check +docker compose -f deploy/community/docker-compose.yml \ + --env-file deploy/community/.env.example \ + run --rm migrate crank-migrate preflight +``` + +The migrator reads only database configuration and retries connection for a bounded startup window. Diagnostics contain stable `code`, `stage`, nullable `version`, and `recovery`, never raw SQL, driver output, database URLs, credentials, or row data. + +Published migrations are immutable and checksummed. Partial schema, unknown extension provenance, checksum drift, and future versions fail closed. Automatic down migrations, `--force`, destructive rollback, and arbitrary SQL input are not supported. Full N/N-1 upgrade and rollback qualification belongs to Story 7.2. + +Version 3 adds nullable canonical Trace ID storage and partial Request/Trace indexes. New application writes provide both identities; historical rows remain honestly nullable and are never assigned fabricated traces. + +For the complete ledger inventory, recovery table, and authoring rules, see the canonical [Russian operator contract](../migrations.md). diff --git a/docs/intro.md b/docs/intro.md index 3f959e1..aa2be0c 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -36,6 +36,22 @@ MCP-клиент - PostgreSQL как основное хранилище; - опциональный Valkey или Redis для служебного кэша. +Исполняемая граница Community описана в +[`docs/capability-inventory.json`](./capability-inventory.json). Только +`implemented` означает доступную возможность; `planned`, `gap` и `blocked` не +считаются готовностью. Resources, Prompts, фоновые Tasks и Load Runs пока +являются planned target, а не возможностями текущей версии. + +Фактический brownfield baseline закреплён в +[`docs/capability-baseline/manifest.json`](./capability-baseline/manifest.json). +Manifest проверяет SHA-256 inventory, required surfaces, taxonomy, bounded +manual checklist и sanitized results. Flaky, skipped, not-run и manual-only +evidence не считаются полным pass; дефекты остаются связаны с flow и owner. + +PostgreSQL schema изменяет только controlled one-shot команда. Admin API и MCP +при startup выполняют read-only compatibility check; операторский порядок и +failure contract описаны в [`docs/migrations.md`](./migrations.md). + ## Демо при первом запуске В примерах окружения включен `CRANK_DEMO_SEED=true`. После первого запуска Crank создает: @@ -47,4 +63,3 @@ MCP-клиент - пример записи в журнале вызовов. Демо можно отключить, указав `CRANK_DEMO_SEED=false`. - diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index cd05861..2f42cca 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -274,4 +274,6 @@ CRANK_MCP_REFRESH_MS=5000 - `400 Bad Request` - неверный MCP-заголовок, session id или JSON-RPC payload. - `429 Too Many Requests` - сработал rate limit. -Runtime-ошибки инструмента возвращаются как структурированный MCP tool error с кодом, сообщением и `request_id`. +Runtime-ошибки инструмента возвращаются как структурированный MCP tool error с кодом, +сообщением, `request_id` и отдельным `trace_id`. Транспортные ответы также содержат +`x-request-id` и `x-trace-id`; отклонённые входные значения в них не отражаются. diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..af9cd01 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,80 @@ +# Миграции PostgreSQL + +Crank использует единственную append-only migration authority в `crank-registry`. `admin-api` и `mcp-server` выполняют только read-only preflight. DDL применяет one-shot binary `crank-migrate`, который Community Compose запускает до readiness сервисов. + +## Безопасная последовательность оператора + +Для source Compose замените `` на: + +```bash +docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example +``` + +Для image Compose используйте `docker compose -f deploy/community/docker-compose.images.yml --env-file .env`. Перед обновлением уже работающей установки выполните строго: + +1. Read-only preflight: ` run --rm migrate crank-migrate preflight`. Exit `0` означает current, exit `2` — ожидаемую `migration_required`; exit `1` запрещает mutation до устранения причины. +2. Создайте и проверьте согласованный backup PostgreSQL и artifact storage. Для первой пустой установки зафиксируйте, что восстанавливать нечего. +3. Проверьте immutable plan: `cargo run -p admin-api --bin crank-migrate -- plan --check` в source checkout либо ` run --rm migrate crank-migrate plan` для образа. +4. Примените sequence: ` run --rm migrate crank-migrate apply`. +5. Повторите preflight и убедитесь в `{"status":"current","version":3}`. +6. Только теперь запускайте long-running services: ` up -d`. + +Обычный `up` также содержит обязательный migration job, но при upgrade он не заменяет предварительные preflight и backup. Migrator делает до десяти bounded попыток подключения с секундной паузой и затем безопасно завершается ошибкой. + +Команда читает только `CRANK_DATABASE_URL`/`POSTGRES_*`. Master key, session secret, bootstrap password, MCP credentials и другие service secrets не входят в её config projection. + +## Фактический brownfield inventory + +| Источник | Исторический владелец/lock | Известный контракт | +|---|---|---| +| `__crank_core_migrations` | `crank-registry`, session lock `0x4352414e4b` | columns `version, description, checksum, applied_at`; v1 checksum `crank-community-baseline-v1` | +| `__crank_mcp_migrations` | MCP session store, session lock `0x4352414e4b4d4350` | columns `version, checksum, applied_at`; v1 checksum `mcp-transport-sessions-v1` | +| `__crank_ext_migrations` | прежний `RegistryExtension`, без общего lock | legacy columns `extension_name, version, applied_at`; checksum отсутствовал | +| `__crank_migrations` | canonical `MigrationAuthority`, transaction lock `0x4352414e4b4d4947` | append-only sequence с version/name/checksum/phase/compatibility | +| `__crank_migration_legacy_audit` | canonical `MigrationAuthority` | только доказуемо сопоставленное legacy provenance | + +Legacy extension row без зарегистрированного exact `(name, version, checksum)` несовместим. Community пока не публиковала extension migrations, поэтому authority не выдумывает им checksum и блокирует такие строки с `legacy_conflict`. + +## Контракт sequence + +Machine plan находится в [`schemas/migration-sequence.json`](schemas/migration-sequence.json) и проверяется командой: + +```bash +cargo run -p admin-api --bin crank-migrate -- plan --check +``` + +- V1 — immutable brownfield baseline с историческим ledger token и отдельным exact-source SHA-256. +- V2 — единый exact-byte expand SQL artifact, создающий canonical ledgers и MCP session schema; его SHA-256 закреплён в executable descriptor. +- V3 — append-only expand для независимого nullable `invocation_logs.trace_id`, canonical-format constraint и partial request/trace indexes; исторические строки остаются `NULL` без fabricated backfill. +- Каждая версия имеет contiguous `i64` version, стабильное имя, lowercase SHA-256, owner, phase, explicit readable schema min/max и backfill policy. +- `migrate` требует bounded cursor/batch policy; `contract` дополнительно требует tracked compatibility evidence и закрытого окна. +- Добавление descriptor без executable implementation блокируется `invalid_contract` до DB I/O. + +Legacy ledgers остаются readable. Down migration, destructive automatic rollback, ledger rewrite, `--force` и arbitrary SQL/path input отсутствуют. Полная N/N-1 upgrade/rollback qualification остаётся Story 7.2. + +## Диагностика и восстановление + +CLI/stderr возвращают bounded JSON: `code`, `stage`, nullable numeric `version`, `recovery`. Database URL, credentials, raw SQL/driver body, row values и host paths не выводятся. + +| Code | Значение | Безопасное действие | +|---|---|---| +| `schema_missing` | Service startup увидел пустую schema | Запустить controlled migration; не разрешать runtime DDL | +| `migration_required` | Schema отстаёт; preflight CLI завершает работу с exit `2` | Проверить backup и выполнить controlled `apply` | +| `checksum_mismatch` | Artifact и ledger не совпали | Сначала проверить immutable application image/release manifest; восстанавливать БД только после доказанной ledger corruption | +| `partial_sequence` | Relation/ledger/structural fingerprint неполон | Не чинить вручную; сопоставить backup и matching artifact | +| `future_version` | База новее приложения | Установить matching application; не откатывать schema автоматически | +| `legacy_conflict` | Legacy provenance невозможно доказать | Сохранить backup и привлечь оператора | +| `lock_timeout` | Другой migration runner удерживает canonical lock | Дождаться завершения и повторить preflight | +| `apply_failed` | Transaction migration откатилась | Проверить matching artifact/backup, затем повторить preflight | +| `storage_unavailable` | PostgreSQL/transport недоступен | Проверить сеть/TLS/права; секреты в diagnostic не копировать | +| `config_invalid` | Database-only config невалиден | Исправить указанный config contract | +| `invalid_command` | Неизвестная CLI команда/аргумент | Использовать только `plan`, `preflight`, `apply` | +| `contract_drift` | Committed machine plan расходится с Rust authority | Перегенерировать только для новой append-only version и проверить diff | +| `invalid_contract` | Descriptor/implementation/window/evidence несовместимы | Исправить authoring contract до любого DB I/O | + +## Правила разработчика + +- DDL, SQL migration assets и canonical advisory lock разрешены только в `crank-registry::migrations`. +- Schema развивается `expand → bounded/resumable migrate → evidence-gated contract`. +- Опубликованные source bytes/checksums не редактируются: добавляется новая version. +- Для каждой version обязательны fresh/current/concurrent/corrupt/rollback/data-preservation tests и explicit Community scope scan. diff --git a/docs/observability.md b/docs/observability.md index 8ae0844..8633c34 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -15,6 +15,7 @@ "target": "admin_api::request_context", "event": "admin.request.completed", "request_id": "019...", + "trace_id": "0af7651916cd43dd8448eb211c80319c", "fields": { "method": "GET", "route": "/api/operations", @@ -38,14 +39,14 @@ Admin API и MCP принимают `x-request-id` как непрозрачны отсутствует или содержит пробелы, управляющие символы, `,`, `;`, не-ASCII символы либо больше 128 байт, Crank создаёт UUIDv7. -Один идентификатор: +Request ID и отдельный Trace ID: -- возвращается в `x-request-id` успешного или ошибочного ответа; -- записывается в корневой span входного запроса; -- передаётся в runtime; +- возвращаются в `x-request-id`/`x-trace-id` успешного или ошибочного ответа; +- записываются в корневой span входного запроса; +- типизированно передаются в runtime; - заменяет статические или полученные из mapping значения `x-request-id` и `x-correlation-id` перед исходящим REST-запросом; -- сохраняется в прикладной истории вызова. +- сохраняются в прикладной истории вызова. `request id` не является `trace id` и не подменяет распределённую трассировку. @@ -214,15 +215,22 @@ series и расход памяти измеряются в истории 1.8; ## Распределённые трассы Crank принимает и передаёт стандартный W3C `traceparent` на границах Admin -API, MCP и исходящих REST-вызовов. `x-request-id` остаётся отдельным -идентификатором запроса. Baggage не извлекается и не передаётся. +API, MCP и исходящих REST-вызовов. Ровно один canonical parent принимается; +duplicate, malformed, uppercase и zero-ID значения заменяются новым local +trace без echo входа. `x-request-id` остаётся отдельным идентификатором +запроса, а `x-trace-id` — безопасным локальным support ID. Caller +`tracestate` ограничен 512 байтами/32 members, baggage — 8192 байтами/64 +members; Community allowlist пуста, поэтому они не извлекаются и не +передаются. Экспорт отключён по умолчанию: при пустых -`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` и `OTEL_EXPORTER_OTLP_ENDPOINT` -provider, batch processor и фоновый поток не создаются. После включения -используется только OTLP/HTTP binary protobuf. Community Compose не включает -Collector или хранилище трасс: оператор подключает внешний совместимый -приёмник. +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` и `OTEL_EXPORTER_OTLP_ENDPOINT` local +provider сохраняет span topology и Trace ID, но exporter, batch processor, +фоновая очередь и сетевой трафик не создаются. Поэтому response, runtime и +Invocation History получают Trace ID независимо от sampling/export. После +включения используется только OTLP/HTTP binary protobuf. Community Compose +не включает Collector или хранилище трасс: оператор подключает внешний +совместимый приёмник. Уровень эксплуатационных журналов не отключает трассы. В OTLP попадают только явно отмеченные spans с внутренней целью `crank::trace`; события diff --git a/docs/runtime-config.md b/docs/runtime-config.md index ab0af26..a0745ee 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -2,6 +2,70 @@ Crank настраивается через переменные окружения. Один и тот же набор переменных используется при запуске из исходников и при запуске готовых Docker-образов. + + +| Environment | Semantic path | Process | Type/unit | Default | Bounds | Sensitivity | Mode | +|---|---|---|---|---|---|---|---| +| `CRANK_DATABASE_URL` | `database.url` | `Shared` | `url/-` | `blank` | `-` | `Secret` | `Effective` | +| `POSTGRES_HOST` | `database.host` | `Shared` | `string/-` | `postgres` | `-` | `Internal` | `Effective` | +| `POSTGRES_PORT` | `database.port` | `Shared` | `u16/port` | `5432` | `1..=65535` | `Public` | `Effective` | +| `POSTGRES_DB` | `database.name` | `Shared` | `string/-` | `crank` | `-` | `Internal` | `Effective` | +| `POSTGRES_USER` | `database.user` | `Shared` | `string/-` | `crank` | `-` | `Internal` | `Effective` | +| `POSTGRES_PASSWORD` | `database.password` | `Shared` | `secret/-` | `configured` | `-` | `Secret` | `Effective` | +| `POSTGRES_MAX_CONNECTIONS` | `database.pool.max_connections` | `Shared` | `u32/connections` | `20` | `1..=1024` | `Public` | `Effective` | +| `POSTGRES_MIN_CONNECTIONS` | `database.pool.min_connections` | `Shared` | `u32/connections` | `2` | `0..=1024` | `Public` | `Effective` | +| `POSTGRES_ACQUIRE_TIMEOUT_MS` | `database.pool.acquire_timeout_ms` | `Shared` | `u64/milliseconds` | `5000` | `1..=300000` | `Public` | `Effective` | +| `POSTGRES_IDLE_TIMEOUT_MS` | `database.pool.idle_timeout_ms` | `Shared` | `u64/milliseconds` | `600000` | `1000..=86400000` | `Public` | `Effective` | +| `POSTGRES_MAX_LIFETIME_MS` | `database.pool.max_lifetime_ms` | `Shared` | `u64/milliseconds` | `1800000` | `1000..=86400000` | `Public` | `Effective` | +| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` | `-` | `Secret` | `Effective` | +| `CRANK_BASE_URL` | `runtime.base_url` | `Shared` | `url/-` | `blank` | `-` | `Internal` | `Effective` | +| `CRANK_RUNTIME_MAX_CONCURRENT_UNARY` | `runtime.max_concurrent_unary` | `Shared` | `u32/requests` | `64` | `1..=65535` | `Public` | `Effective` | +| `CRANK_CACHE_BACKEND` | `cache.backend` | `Shared` | `enum/-` | `memory` | `-` | `Public` | `Effective` | +| `CRANK_CACHE_URL` | `cache.url` | `Shared` | `url/-` | `blank` | `-` | `Secret` | `Effective` | +| `CRANK_CACHE_DEFAULT_TTL_MS` | `cache.default_ttl_ms` | `Shared` | `u64/milliseconds` | `blank` | `1..=86400000` | `Public` | `DeprecatedNoEffect` | +| `CRANK_OUTBOUND_ALLOWED_HOSTS` | `outbound.allowed_hosts` | `Shared` | `host_list/-` | `` | `-` | `Internal` | `Effective` | +| `CRANK_OUTBOUND_DENIED_HOSTS` | `outbound.denied_hosts` | `Shared` | `host_list/-` | `` | `-` | `Internal` | `Effective` | +| `CRANK_OUTBOUND_MAX_RESPONSE_BYTES` | `outbound.max_response_bytes` | `Shared` | `u64/bytes` | `4194304` | `1..=67108864` | `Public` | `Effective` | +| `CRANK_ENVIRONMENT` | `observability.environment` | `Shared` | `label/-` | `development` | `-` | `Public` | `Effective` | +| `CRANK_LOG_LEVEL` | `observability.log_filter` | `Shared` | `string/-` | `blank` | `-` | `Public` | `Effective` | +| `CRANK_SENTRY_DSN` | `observability.sentry_dsn` | `Shared` | `url/-` | `blank` | `-` | `Secret` | `Effective` | +| `CRANK_METRICS_ENABLED` | `observability.metrics.enabled` | `Shared` | `bool/-` | `true` | `-` | `Public` | `Effective` | +| `CRANK_METRICS_BEARER_TOKEN` | `observability.metrics.bearer_token` | `Shared` | `secret/-` | `blank` | `-` | `Secret` | `Effective` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `observability.otlp.endpoint` | `Shared` | `url/-` | `blank` | `-` | `Internal` | `Effective` | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `observability.otlp.traces_endpoint` | `Shared` | `url/-` | `blank` | `-` | `Internal` | `Effective` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `observability.otlp.protocol` | `Shared` | `enum/-` | `http/protobuf` | `-` | `Public` | `Effective` | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | `observability.otlp.traces_protocol` | `Shared` | `enum/-` | `blank` | `-` | `Public` | `Effective` | +| `OTEL_EXPORTER_OTLP_TIMEOUT` | `observability.otlp.timeout` | `Shared` | `duration/milliseconds` | `10000` | `1..=300000` | `Public` | `Effective` | +| `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` | `observability.otlp.traces_timeout` | `Shared` | `duration/milliseconds` | `blank` | `1..=300000` | `Public` | `Effective` | +| `OTEL_EXPORTER_OTLP_HEADERS` | `observability.otlp.headers` | `Shared` | `headers/-` | `blank` | `-` | `Secret` | `Effective` | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `observability.otlp.traces_headers` | `Shared` | `headers/-` | `blank` | `-` | `Secret` | `Effective` | +| `OTEL_BSP_MAX_QUEUE_SIZE` | `observability.otlp.max_queue_size` | `Shared` | `u32/spans` | `2048` | `1..=65536` | `Public` | `Effective` | +| `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` | `observability.otlp.max_export_batch_size` | `Shared` | `u32/spans` | `512` | `1..=65536` | `Public` | `Effective` | +| `OTEL_BSP_SCHEDULE_DELAY` | `observability.otlp.schedule_delay` | `Shared` | `duration/milliseconds` | `5000` | `1..=300000` | `Public` | `Effective` | +| `OTEL_BSP_EXPORT_TIMEOUT` | `observability.otlp.export_timeout` | `Shared` | `duration/milliseconds` | `30000` | `1..=300000` | `Public` | `Effective` | +| `CRANK_ADMIN_BIND` | `admin.bind` | `AdminApi` | `socket/-` | `0.0.0.0:3001` | `-` | `Internal` | `Effective` | +| `CRANK_ADMIN_METRICS_BIND` | `admin.metrics_bind` | `AdminApi` | `socket/-` | `127.0.0.1:9464` | `-` | `Internal` | `Effective` | +| `CRANK_STORAGE_ROOT` | `admin.storage_root` | `AdminApi` | `absolute_path/-` | `/var/lib/crank/storage` | `-` | `Internal` | `Effective` | +| `CRANK_ADMIN_RATE_LIMIT_RPS` | `admin.rate_limit.rps` | `AdminApi` | `u32/requests_per_second` | `30` | `1..=100000` | `Public` | `Effective` | +| `CRANK_ADMIN_RATE_LIMIT_BURST` | `admin.rate_limit.burst` | `AdminApi` | `u32/requests` | `60` | `1..=1000000` | `Public` | `Effective` | +| `CRANK_INVOCATION_LOG_RETENTION_DAYS` | `admin.invocation_log_retention_days` | `AdminApi` | `u32/days` | `30` | `1..=36500` | `Public` | `Effective` | +| `CRANK_SESSION_SECRET` | `admin.session.secret` | `AdminApi` | `secret/-` | `required/blank` | `-` | `Secret` | `Effective` | +| `CRANK_PASSWORD_PEPPER` | `admin.password_pepper` | `AdminApi` | `secret/-` | `required/blank` | `-` | `Secret` | `Effective` | +| `CRANK_SESSION_TTL_HOURS` | `admin.session.ttl_hours` | `AdminApi` | `u32/hours` | `24` | `1..=8760` | `Public` | `Effective` | +| `CRANK_TRUST_FORWARDED_HEADERS` | `admin.trust_forwarded_headers` | `AdminApi` | `bool/-` | `false` | `-` | `Public` | `Effective` | +| `CRANK_BOOTSTRAP_ADMIN_EMAIL` | `admin.bootstrap.email` | `AdminApi` | `string/-` | `required/blank` | `-` | `Internal` | `Effective` | +| `CRANK_BOOTSTRAP_ADMIN_PASSWORD` | `admin.bootstrap.password` | `AdminApi` | `secret/-` | `required/blank` | `-` | `Secret` | `Effective` | +| `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` | `admin.bootstrap.display_name` | `AdminApi` | `string/-` | `Crank Owner` | `-` | `Internal` | `Effective` | +| `CRANK_DEMO_SEED` | `admin.demo_seed` | `AdminApi` | `bool/-` | `false` | `-` | `Public` | `Effective` | +| `CRANK_MCP_BIND` | `mcp.bind` | `McpServer` | `socket/-` | `0.0.0.0:3002` | `-` | `Internal` | `Effective` | +| `CRANK_MCP_METRICS_BIND` | `mcp.metrics_bind` | `McpServer` | `socket/-` | `127.0.0.1:9465` | `-` | `Internal` | `Effective` | +| `CRANK_MCP_REFRESH_MS` | `mcp.refresh_ms` | `McpServer` | `u64/milliseconds` | `5000` | `100..=3600000` | `Public` | `Effective` | +| `CRANK_MCP_RATE_LIMIT_RPS` | `mcp.rate_limit.rps` | `McpServer` | `u32/requests_per_second` | `60` | `1..=100000` | `Public` | `Effective` | +| `CRANK_MCP_RATE_LIMIT_BURST` | `mcp.rate_limit.burst` | `McpServer` | `u32/requests` | `120` | `1..=1000000` | `Public` | `Effective` | +| `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS` | `runtime.max_concurrent_sessions` | `McpServer` | `u32/sessions` | `16` | `1..=65535` | `Public` | `Effective` | + + + ## PostgreSQL Обязательные параметры: @@ -22,6 +86,13 @@ Crank настраивается через переменные окружен Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`. +`CRANK_DATABASE_URL` — compatibility-форма для существующих установок. Она +содержит credentials и поэтому никогда не выводится в diagnostics или +fingerprint. URL нельзя смешивать с явно заданными `POSTGRES_HOST`, +`POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER` или `POSTGRES_PASSWORD`: +конфликт останавливает startup. Для новых установок canonical-формой остаются +раздельные `POSTGRES_*` параметры. + ## HTTP-сервисы - `CRANK_ADMIN_BIND` - адрес `admin-api`, например `0.0.0.0:3001`. @@ -79,11 +150,9 @@ Demo seed идемпотентный: повторный старт не соз ## Runtime limits - `CRANK_RUNTIME_MAX_CONCURRENT_UNARY` -- `CRANK_RUNTIME_MAX_CONCURRENT_WINDOW` - `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS` -- `CRANK_RUNTIME_MAX_CONCURRENT_JOBS` -Эти настройки ограничивают параллельное выполнение операций и служебных задач. +Эти настройки ограничивают параллельное выполнение unary-запросов и MCP-сессий. ## Исходящие HTTP-запросы @@ -124,9 +193,12 @@ CRANK_CACHE_BACKEND=memory ```env CRANK_CACHE_BACKEND=valkey CRANK_CACHE_URL=redis://valkey:6379/0 -CRANK_CACHE_DEFAULT_TTL_MS=60000 ``` +`CRANK_CACHE_DEFAULT_TTL_MS` в прежних шаблонах не имел runtime consumer. +Непустое значение теперь отклоняется как deprecated no-effect configuration; +TTL задаётся владельцем конкретного cache operation. + Внешний кэш используется для служебного краткоживущего состояния: rate limiting, replay guard и опубликованные каталоги MCP-инструментов. ## Логи @@ -141,6 +213,11 @@ CRANK_CACHE_DEFAULT_TTL_MS=60000 Поля с паролями, токенами, ключами, cookie, authorization, query, полным телом или результатом очищаются до сериализации. +`crank-migrate` использует только database-поля этого контракта. Ему не +требуются и не должны передаваться master key, session/bootstrap secrets, +runtime, metrics или OTLP configuration. Команды и recovery contract описаны +в [migrations.md](migrations.md). + Пример: ```env diff --git a/docs/schemas/capability-baseline.schema.json b/docs/schemas/capability-baseline.schema.json new file mode 100644 index 0000000..48dab94 --- /dev/null +++ b/docs/schemas/capability-baseline.schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crank.local/schemas/capability-baseline.schema.json", + "title": "Crank Community Capability Baseline Manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "baseline_version", + "artifacts" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "baseline_version": { + "type": "string", + "pattern": "^[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}\\.[1-9][0-9]*$" + }, + "artifacts": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/artifact" + } + } + }, + "$defs": { + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "path", + "sha256" + ], + "properties": { + "kind": { + "enum": [ + "inventory", + "required_surfaces", + "taxonomy", + "checklist", + "results" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.\\.?($|/))(?!.*\\\\).+$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "taxonomy": { + "type": "object", + "additionalProperties": false, + "required": ["baseline_version", "implementation_statuses", "execution_verdicts", "evidence_modes", "full_pass"], + "properties": { + "baseline_version": {"type": "string", "pattern": "^[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}\\.[1-9][0-9]*$"}, + "implementation_statuses": {"type": "array", "minItems": 4, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["implemented", "planned", "gap", "blocked"]}}, + "execution_verdicts": {"type": "array", "minItems": 6, "maxItems": 6, "uniqueItems": true, "items": {"enum": ["pass", "fail", "blocked", "skipped", "flaky", "not_run"]}}, + "evidence_modes": {"type": "array", "minItems": 2, "maxItems": 2, "uniqueItems": true, "items": {"enum": ["automated", "manual_only"]}}, + "manual_only_rule": {"type": "string", "minLength": 1, "maxLength": 2048}, + "non_pass_rule": {"type": "string", "minLength": 1, "maxLength": 2048}, + "full_pass": { + "type": "object", + "additionalProperties": false, + "required": ["implementation_status", "execution_verdict", "evidence_mode"], + "properties": { + "implementation_status": {"const": "implemented"}, + "execution_verdict": {"const": "pass"}, + "evidence_mode": {"const": "automated"} + } + } + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "command_id", "flow_ids", "execution_verdict", "evidence_mode", "accepted", "source_report_sha256", "source_revision", "environment_class", "collector"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "command_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "flow_ids": {"type": "array", "minItems": 1, "maxItems": 1000, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "execution_verdict": {"enum": ["pass", "fail", "blocked", "skipped", "flaky", "not_run"]}, + "evidence_mode": {"enum": ["automated", "manual_only"]}, + "accepted": {"type": "boolean"}, + "source_report_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "source_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "environment_class": {"type": "string", "minLength": 1, "maxLength": 64}, + "collector": {"const": "capability-baseline-collector-v1"}, + "summary": {"type": "object", "maxProperties": 16}, + "safe_outcome": {"type": "object", "maxProperties": 16} + } + }, + "defect": { + "type": "object", + "additionalProperties": false, + "required": ["id", "severity", "steps", "contract", "owner", "flow_ids", "next_action"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "severity": {"enum": ["Critical", "High", "Medium", "Low"]}, + "steps": {"type": "array", "minItems": 1, "maxItems": 20, "items": {"type": "string", "minLength": 1, "maxLength": 1024}}, + "contract": {"type": "string", "minLength": 1, "maxLength": 2048}, + "owner": {"type": "string", "minLength": 1, "maxLength": 128}, + "flow_ids": {"type": "array", "minItems": 1, "maxItems": 1000, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "next_action": {"type": "string", "minLength": 1, "maxLength": 2048} + } + }, + "manual_result": { + "type": "object", + "additionalProperties": false, + "required": ["check_id", "evidence_mode", "execution_verdict", "flow_ids", "next_evidence"], + "properties": { + "check_id": {"type": "string", "pattern": "^UI-[0-9]{2}$"}, + "evidence_mode": {"const": "manual_only"}, + "execution_verdict": {"enum": ["pass", "fail", "blocked", "skipped", "flaky", "not_run"]}, + "flow_ids": {"type": "array", "minItems": 1, "maxItems": 1000, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "next_evidence": {"type": "string", "minLength": 1, "maxLength": 2048} + } + }, + "results": { + "type": "object", + "additionalProperties": false, + "required": ["baseline_version", "source_revision", "environment_class", "runs", "manual_results", "defects"], + "properties": { + "baseline_version": {"type": "string", "maxLength": 32}, + "source_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "environment_class": {"type": "string", "minLength": 1, "maxLength": 64}, + "runs": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/run"}}, + "manual_results": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/manual_result"}}, + "defects": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/defect"}} + } + } + } +} diff --git a/docs/schemas/capability-inventory.schema.json b/docs/schemas/capability-inventory.schema.json new file mode 100644 index 0000000..1791b82 --- /dev/null +++ b/docs/schemas/capability-inventory.schema.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crank.local/schemas/capability-inventory.schema.json", + "title": "Crank Community Capability Inventory", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "product", + "flows" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "product": { + "const": "crank-community" + }, + "flows": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { + "$ref": "#/$defs/flow" + } + } + }, + "$defs": { + "flow": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "requirements", + "user_outcome", + "owner", + "status", + "capabilities", + "evidence" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": { + "enum": [ + "ui", + "api", + "mcp" + ] + }, + "requirements": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^FR-[1-9][0-9]*$" + } + }, + "user_outcome": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "[\\s\\S]*\\S[\\s\\S]*" + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "[\\s\\S]*\\S[\\s\\S]*" + }, + "status": { + "enum": [ + "implemented", + "planned", + "gap", + "blocked" + ] + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "items": { + "enum": [ + "tools", + "resources", + "prompts", + "tasks", + "load_runs" + ] + } + }, + "evidence": { + "$ref": "#/$defs/evidence" + }, + "notes": { + "type": "string", + "maxLength": 4096 + } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "automated", + "manual" + ], + "properties": { + "automated": { + "$ref": "#/$defs/evidencePaths" + }, + "manual": { + "$ref": "#/$defs/evidencePaths" + } + } + }, + "evidencePaths": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.\\.?($|/))(?!.*\\\\).+$" + } + } + } +} diff --git a/docs/schemas/migration-sequence.json b/docs/schemas/migration-sequence.json new file mode 100644 index 0000000..009ee29 --- /dev/null +++ b/docs/schemas/migration-sequence.json @@ -0,0 +1 @@ +{"schema_version":1,"sequence":[{"backfill":{"kind":"none"},"checksum":"crank-community-baseline-v1","compatibility":"legacy-baseline","contract_evidence":null,"name":"community-baseline-v1","owner":"crank-registry","phase":"expand","readable_schema_max":1,"readable_schema_min":1,"source_digest":"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675","transactional":true,"version":1},{"backfill":{"kind":"none"},"checksum":"1908b97ca7fe8a85d146b0eebf007d5018a9ff406cb099614270f3428ec1db48","compatibility":"n-minus-one-readable","contract_evidence":null,"name":"legacy-consolidation-v2","owner":"crank-registry","phase":"expand","readable_schema_max":2,"readable_schema_min":1,"source_digest":"1908b97ca7fe8a85d146b0eebf007d5018a9ff406cb099614270f3428ec1db48","transactional":true,"version":2},{"backfill":{"kind":"none"},"checksum":"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94","compatibility":"n-minus-one-readable","contract_evidence":null,"name":"request-trace-identity-v3","owner":"crank-registry","phase":"expand","readable_schema_max":3,"readable_schema_min":2,"source_digest":"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94","transactional":true,"version":3}]} diff --git a/docs/schemas/runtime-config.schema.json b/docs/schemas/runtime-config.schema.json new file mode 100644 index 0000000..40e0760 --- /dev/null +++ b/docs/schemas/runtime-config.schema.json @@ -0,0 +1,898 @@ +{ + "schema_version": 1, + "generated_by": "crank-config", + "fields": [ + { + "semantic_path": "database.url", + "env_name": "CRANK_DATABASE_URL", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": "legacy URL form", + "rules": [ + "takes precedence over generated default-valued POSTGRES_HOST/PORT/DB/USER/PASSWORD", + "conflicts with any non-default decomposed database value" + ] + }, + { + "semantic_path": "database.host", + "env_name": "POSTGRES_HOST", + "process": "shared", + "value_type": "string", + "unit": null, + "default": "postgres", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.port", + "env_name": "POSTGRES_PORT", + "process": "shared", + "value_type": "u16", + "unit": "port", + "default": "5432", + "required": false, + "minimum": 1, + "maximum": 65535, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.name", + "env_name": "POSTGRES_DB", + "process": "shared", + "value_type": "string", + "unit": null, + "default": "crank", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.user", + "env_name": "POSTGRES_USER", + "process": "shared", + "value_type": "string", + "unit": null, + "default": "crank", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.password", + "env_name": "POSTGRES_PASSWORD", + "process": "shared", + "value_type": "secret", + "unit": null, + "default": "configured", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.pool.max_connections", + "env_name": "POSTGRES_MAX_CONNECTIONS", + "process": "shared", + "value_type": "u32", + "unit": "connections", + "default": "20", + "required": false, + "minimum": 1, + "maximum": 1024, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [ + "must be >= min_connections" + ] + }, + { + "semantic_path": "database.pool.min_connections", + "env_name": "POSTGRES_MIN_CONNECTIONS", + "process": "shared", + "value_type": "u32", + "unit": "connections", + "default": "2", + "required": false, + "minimum": 0, + "maximum": 1024, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [ + "must be <= max_connections" + ] + }, + { + "semantic_path": "database.pool.acquire_timeout_ms", + "env_name": "POSTGRES_ACQUIRE_TIMEOUT_MS", + "process": "shared", + "value_type": "u64", + "unit": "milliseconds", + "default": "5000", + "required": false, + "minimum": 1, + "maximum": 300000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.pool.idle_timeout_ms", + "env_name": "POSTGRES_IDLE_TIMEOUT_MS", + "process": "shared", + "value_type": "u64", + "unit": "milliseconds", + "default": "600000", + "required": false, + "minimum": 1000, + "maximum": 86400000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "database.pool.max_lifetime_ms", + "env_name": "POSTGRES_MAX_LIFETIME_MS", + "process": "shared", + "value_type": "u64", + "unit": "milliseconds", + "default": "1800000", + "required": false, + "minimum": 1000, + "maximum": 86400000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "runtime.master_key", + "env_name": "CRANK_MASTER_KEY", + "process": "shared", + "value_type": "secret", + "unit": null, + "default": null, + "required": true, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "runtime.base_url", + "env_name": "CRANK_BASE_URL", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "runtime.max_concurrent_unary", + "env_name": "CRANK_RUNTIME_MAX_CONCURRENT_UNARY", + "process": "shared", + "value_type": "u32", + "unit": "requests", + "default": "64", + "required": false, + "minimum": 1, + "maximum": 65535, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "cache.backend", + "env_name": "CRANK_CACHE_BACKEND", + "process": "shared", + "value_type": "enum", + "unit": null, + "default": "memory", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": "redis value is deprecated in favor of valkey", + "rules": [ + "external backend requires cache.url" + ] + }, + { + "semantic_path": "cache.url", + "env_name": "CRANK_CACHE_URL", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [ + "forbidden with memory backend" + ] + }, + { + "semantic_path": "cache.default_ttl_ms", + "env_name": "CRANK_CACHE_DEFAULT_TTL_MS", + "process": "shared", + "value_type": "u64", + "unit": "milliseconds", + "default": null, + "required": false, + "minimum": 1, + "maximum": 86400000, + "sensitivity": "public", + "mode": "deprecated_no_effect", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "outbound.allowed_hosts", + "env_name": "CRANK_OUTBOUND_ALLOWED_HOSTS", + "process": "shared", + "value_type": "host_list", + "unit": null, + "default": "", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "outbound.denied_hosts", + "env_name": "CRANK_OUTBOUND_DENIED_HOSTS", + "process": "shared", + "value_type": "host_list", + "unit": null, + "default": "", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [ + "deny entries override allow entries" + ] + }, + { + "semantic_path": "outbound.max_response_bytes", + "env_name": "CRANK_OUTBOUND_MAX_RESPONSE_BYTES", + "process": "shared", + "value_type": "u64", + "unit": "bytes", + "default": "4194304", + "required": false, + "minimum": 1, + "maximum": 67108864, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.environment", + "env_name": "CRANK_ENVIRONMENT", + "process": "shared", + "value_type": "label", + "unit": null, + "default": "development", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.log_filter", + "env_name": "CRANK_LOG_LEVEL", + "process": "shared", + "value_type": "string", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.sentry_dsn", + "env_name": "CRANK_SENTRY_DSN", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.metrics.enabled", + "env_name": "CRANK_METRICS_ENABLED", + "process": "shared", + "value_type": "bool", + "unit": null, + "default": "true", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": "yes/no/on/off spellings are deprecated", + "rules": [] + }, + { + "semantic_path": "observability.metrics.bearer_token", + "env_name": "CRANK_METRICS_BEARER_TOKEN", + "process": "shared", + "value_type": "secret", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [ + "required when an enabled metrics bind is non-loopback" + ] + }, + { + "semantic_path": "observability.otlp.endpoint", + "env_name": "OTEL_EXPORTER_OTLP_ENDPOINT", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.traces_endpoint", + "env_name": "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "process": "shared", + "value_type": "url", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [ + "overrides generic OTLP endpoint" + ] + }, + { + "semantic_path": "observability.otlp.protocol", + "env_name": "OTEL_EXPORTER_OTLP_PROTOCOL", + "process": "shared", + "value_type": "enum", + "unit": null, + "default": "http/protobuf", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.traces_protocol", + "env_name": "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "process": "shared", + "value_type": "enum", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.timeout", + "env_name": "OTEL_EXPORTER_OTLP_TIMEOUT", + "process": "shared", + "value_type": "duration", + "unit": "milliseconds", + "default": "10000", + "required": false, + "minimum": 1, + "maximum": 300000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.traces_timeout", + "env_name": "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + "process": "shared", + "value_type": "duration", + "unit": "milliseconds", + "default": null, + "required": false, + "minimum": 1, + "maximum": 300000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.headers", + "env_name": "OTEL_EXPORTER_OTLP_HEADERS", + "process": "shared", + "value_type": "headers", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.traces_headers", + "env_name": "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "process": "shared", + "value_type": "headers", + "unit": null, + "default": null, + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.max_queue_size", + "env_name": "OTEL_BSP_MAX_QUEUE_SIZE", + "process": "shared", + "value_type": "u32", + "unit": "spans", + "default": "2048", + "required": false, + "minimum": 1, + "maximum": 65536, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.max_export_batch_size", + "env_name": "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", + "process": "shared", + "value_type": "u32", + "unit": "spans", + "default": "512", + "required": false, + "minimum": 1, + "maximum": 65536, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [ + "must be <= max_queue_size" + ] + }, + { + "semantic_path": "observability.otlp.schedule_delay", + "env_name": "OTEL_BSP_SCHEDULE_DELAY", + "process": "shared", + "value_type": "duration", + "unit": "milliseconds", + "default": "5000", + "required": false, + "minimum": 1, + "maximum": 300000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "observability.otlp.export_timeout", + "env_name": "OTEL_BSP_EXPORT_TIMEOUT", + "process": "shared", + "value_type": "duration", + "unit": "milliseconds", + "default": "30000", + "required": false, + "minimum": 1, + "maximum": 300000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.bind", + "env_name": "CRANK_ADMIN_BIND", + "process": "admin_api", + "value_type": "socket", + "unit": null, + "default": "0.0.0.0:3001", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.metrics_bind", + "env_name": "CRANK_ADMIN_METRICS_BIND", + "process": "admin_api", + "value_type": "socket", + "unit": null, + "default": "127.0.0.1:9464", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.storage_root", + "env_name": "CRANK_STORAGE_ROOT", + "process": "admin_api", + "value_type": "absolute_path", + "unit": null, + "default": "/var/lib/crank/storage", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.rate_limit.rps", + "env_name": "CRANK_ADMIN_RATE_LIMIT_RPS", + "process": "admin_api", + "value_type": "u32", + "unit": "requests_per_second", + "default": "30", + "required": false, + "minimum": 1, + "maximum": 100000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.rate_limit.burst", + "env_name": "CRANK_ADMIN_RATE_LIMIT_BURST", + "process": "admin_api", + "value_type": "u32", + "unit": "requests", + "default": "60", + "required": false, + "minimum": 1, + "maximum": 1000000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [ + "must be >= admin rate RPS" + ] + }, + { + "semantic_path": "admin.invocation_log_retention_days", + "env_name": "CRANK_INVOCATION_LOG_RETENTION_DAYS", + "process": "admin_api", + "value_type": "u32", + "unit": "days", + "default": "30", + "required": false, + "minimum": 1, + "maximum": 36500, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.session.secret", + "env_name": "CRANK_SESSION_SECRET", + "process": "admin_api", + "value_type": "secret", + "unit": null, + "default": null, + "required": true, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.password_pepper", + "env_name": "CRANK_PASSWORD_PEPPER", + "process": "admin_api", + "value_type": "secret", + "unit": null, + "default": null, + "required": true, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.session.ttl_hours", + "env_name": "CRANK_SESSION_TTL_HOURS", + "process": "admin_api", + "value_type": "u32", + "unit": "hours", + "default": "24", + "required": false, + "minimum": 1, + "maximum": 8760, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.trust_forwarded_headers", + "env_name": "CRANK_TRUST_FORWARDED_HEADERS", + "process": "admin_api", + "value_type": "bool", + "unit": null, + "default": "false", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": "yes/no/on/off spellings are deprecated", + "rules": [] + }, + { + "semantic_path": "admin.bootstrap.email", + "env_name": "CRANK_BOOTSTRAP_ADMIN_EMAIL", + "process": "admin_api", + "value_type": "string", + "unit": null, + "default": null, + "required": true, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.bootstrap.password", + "env_name": "CRANK_BOOTSTRAP_ADMIN_PASSWORD", + "process": "admin_api", + "value_type": "secret", + "unit": null, + "default": null, + "required": true, + "minimum": null, + "maximum": null, + "sensitivity": "secret", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.bootstrap.display_name", + "env_name": "CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", + "process": "admin_api", + "value_type": "string", + "unit": null, + "default": "Crank Owner", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "admin.demo_seed", + "env_name": "CRANK_DEMO_SEED", + "process": "admin_api", + "value_type": "bool", + "unit": null, + "default": "false", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "public", + "mode": "effective", + "compatibility": "yes/no/on/off spellings are deprecated", + "rules": [] + }, + { + "semantic_path": "mcp.bind", + "env_name": "CRANK_MCP_BIND", + "process": "mcp_server", + "value_type": "socket", + "unit": null, + "default": "0.0.0.0:3002", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "mcp.metrics_bind", + "env_name": "CRANK_MCP_METRICS_BIND", + "process": "mcp_server", + "value_type": "socket", + "unit": null, + "default": "127.0.0.1:9465", + "required": false, + "minimum": null, + "maximum": null, + "sensitivity": "internal", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "mcp.refresh_ms", + "env_name": "CRANK_MCP_REFRESH_MS", + "process": "mcp_server", + "value_type": "u64", + "unit": "milliseconds", + "default": "5000", + "required": false, + "minimum": 100, + "maximum": 3600000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "mcp.rate_limit.rps", + "env_name": "CRANK_MCP_RATE_LIMIT_RPS", + "process": "mcp_server", + "value_type": "u32", + "unit": "requests_per_second", + "default": "60", + "required": false, + "minimum": 1, + "maximum": 100000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + }, + { + "semantic_path": "mcp.rate_limit.burst", + "env_name": "CRANK_MCP_RATE_LIMIT_BURST", + "process": "mcp_server", + "value_type": "u32", + "unit": "requests", + "default": "120", + "required": false, + "minimum": 1, + "maximum": 1000000, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [ + "must be >= MCP rate RPS" + ] + }, + { + "semantic_path": "runtime.max_concurrent_sessions", + "env_name": "CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS", + "process": "mcp_server", + "value_type": "u32", + "unit": "sessions", + "default": "16", + "required": false, + "minimum": 1, + "maximum": 65535, + "sensitivity": "public", + "mode": "effective", + "compatibility": null, + "rules": [] + } + ], + "deployment_only_fields": [ + "COMPOSE_PROJECT_NAME", + "POSTGRES_PUBLISH_BIND", + "POSTGRES_PUBLISH_PORT", + "CRANK_ADMIN_API_IMAGE", + "CRANK_MCP_SERVER_IMAGE", + "CRANK_UI_IMAGE", + "CRANK_PUBLISH_BIND", + "CRANK_ADMIN_PUBLISH_PORT", + "CRANK_MCP_PUBLISH_PORT", + "CRANK_UI_PUBLISH_PORT", + "VALKEY_PUBLISH_BIND", + "VALKEY_PUBLISH_PORT" + ] +} diff --git a/justfile b/justfile index d7ef3dd..7352518 100644 --- a/justfile +++ b/justfile @@ -7,9 +7,21 @@ fmt-check: tooling-test: python3 -m unittest discover -s tests/unit +capability-inventory-check: + python3 scripts/validate-capability-inventory.py --root . --inventory docs/capability-inventory.json --schema docs/schemas/capability-inventory.schema.json $(for n in $(seq 1 54); do printf -- '--required-fr FR-%s ' "$n"; done) + python3 scripts/validate-capability-baseline.py --root . --manifest docs/capability-baseline/manifest.json --schema docs/schemas/capability-baseline.schema.json + community-scope-check: scripts/check-community-scope.sh +config-contract-check: + cargo run -p crank-config --bin crank-config-contract -- --check + python3 scripts/check-runtime-config.py --root . + python3 scripts/check-config-boundaries.py --root . + +migration-contract-check: + cargo run -p admin-api --bin crank-migrate -- plan --check + rust-boundaries: scripts/check-rust-boundaries.sh @@ -26,7 +38,7 @@ clippy: cargo clippy --workspace --all-targets --all-features -- -D warnings test: - cargo test --workspace --all-targets + cargo test --workspace --all-targets -- --test-threads=1 sqlx-prepare database_url: DATABASE_URL={{database_url}} cargo sqlx prepare --workspace -- --all-targets @@ -36,7 +48,10 @@ sqlx-check: verify: just tooling-test + just capability-inventory-check just community-scope-check + just config-contract-check + just migration-contract-check just rust-boundaries just rust-code-health just dependencies diff --git a/scripts/README.md b/scripts/README.md index c14aae3..072b073 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -47,6 +47,11 @@ CRANK_STAGING_ADMIN_PASSWORD=secret \ CRANK_PRODUCT_SMOKE_KEEP_ASSETS=1 ./scripts/authenticated-product-smoke.sh https://crank.example.com ``` +Smoke выполняет Admin test-run до publish и фиксирует точные Operation version +и Agent revision. Для split-port local stack можно передать `--mcp-base-url` и +пустой `--mcp-path-prefix`; `--summary-output` пишет bounded safe summary без +ключей, cookies и payloads. + ## `check-rust-code-health.sh` Проверяет базовые правила сопровождаемости Rust-кода: @@ -73,6 +78,28 @@ CRANK_PRODUCT_SMOKE_KEEP_ASSETS=1 ./scripts/authenticated-product-smoke.sh https ./scripts/check-rust-boundaries.sh ``` +## Typed runtime configuration contract + +Единый Rust registry генерирует machine contract, sections `.env.example` и +таблицу параметров. Проверка drift не изменяет файлы: + +```bash +cargo run -p crank-config --bin crank-config-contract -- --check +python3 scripts/check-runtime-config.py --root . +python3 scripts/check-config-boundaries.py --root . +``` + +После намеренного изменения registry artifacts обновляются через +`crank-config-contract --write`, просматриваются в diff и проверяются командой +`just config-contract-check`. Explicit `--files` у boundary checker-а +позволяет проверять новые/untracked Rust-файлы. + +## Versioned migration contract + +`just migration-contract-check` сравнивает canonical Rust migration sequence с +committed machine contract. PostgreSQL DDL вне `crank-registry::migrations` +блокируется Rust module boundary check. + ## `check-community-scope.sh` Проверяет, что в community-репозиторий не попали функции и тексты за пределами @@ -87,3 +114,51 @@ Unit-тесты checker-а лежат в `tests/unit`: ```bash python3 -m unittest discover -s tests/unit ``` + +Новые или ещё не tracked файлы по умолчанию не видны wrapper-у. Перед handoff +передайте только файлы текущего изменения явно: + +```bash +python3 scripts/check-community-scope.py --root . --files +``` + +## `validate-capability-inventory.py` + +Проверяет canonical `docs/capability-inventory.json` по versioned schema, +обязательным FR, Community boundary и существующим evidence-ссылкам: + +```bash +python3 scripts/validate-capability-inventory.py \ + --root . \ + --inventory docs/capability-inventory.json \ + --schema docs/schemas/capability-inventory.schema.json \ + $(for n in $(seq 1 54); do printf -- '--required-fr FR-%s ' "$n"; done) +``` + +Статусы `planned`, `gap` и `blocked` валидны, но не считаются pass. + +## Capability baseline tools + +`collect-capability-baseline.py` принимает только allowlisted command reports +или bounded Playwright JSON, вычисляет verdict и удаляет raw output, paths и +attachments. Retry-pass становится `flaky`, а skipped/missing/non-zero result +не становится pass. + +```bash +python3 scripts/collect-capability-baseline.py playwright \ + --report \ + --output \ + --source-revision \ + --environment-class community-test \ + --flow-id ui-operation-lifecycle +``` + +После review candidate и обновления canonical artifacts пересчитайте exact-byte +SHA-256 в manifest и запустите: + +```bash +python3 scripts/validate-capability-baseline.py \ + --root . \ + --manifest docs/capability-baseline/manifest.json \ + --schema docs/schemas/capability-baseline.schema.json +``` diff --git a/scripts/authenticated-product-smoke.py b/scripts/authenticated-product-smoke.py index 5597ccf..25f2163 100755 --- a/scripts/authenticated-product-smoke.py +++ b/scripts/authenticated-product-smoke.py @@ -7,6 +7,7 @@ import time import urllib.error import urllib.request from dataclasses import dataclass +from pathlib import Path from typing import Any @@ -20,6 +21,19 @@ class SmokeError(RuntimeError): pass +def safe_error(stage: str, code: str, status: int | None = None) -> SmokeError: + message = f"stage={stage[:64]} code={code[:96]}" + if status is not None: + message += f" status={status}" + return SmokeError(message[:256]) + + +def require_object(value: Any, stage: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise safe_error(stage, "invalid_response") + return value + + @dataclass class JsonResponse: status: int @@ -94,8 +108,14 @@ def build_operation_payload(name: str, upstream_base_url: str) -> dict[str, Any] } -def agent_mcp_url(base_url: str, workspace_slug: str, agent_slug: str) -> str: - return f"{base_url.rstrip('/')}/mcp/v1/{workspace_slug}/{agent_slug}" +def agent_mcp_url( + base_url: str, + workspace_slug: str, + agent_slug: str, + path_prefix: str = "/mcp", +) -> str: + normalized_prefix = "/" + path_prefix.strip("/") if path_prefix.strip("/") else "" + return f"{base_url.rstrip('/')}{normalized_prefix}/v1/{workspace_slug}/{agent_slug}" def tools_call_payload(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: @@ -150,17 +170,30 @@ class Client: try: response = self.opener.open(request, timeout=self.timeout_seconds) status = response.status - raw = response.read().decode("utf-8") - body = json.loads(raw) if raw else None + raw_bytes = response.read(1_048_577) + if len(raw_bytes) > 1_048_576: + raise safe_error("http", "response_too_large", status=status) + try: + raw = raw_bytes.decode("utf-8") + body = json.loads(raw) if raw else None + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as error: + raise safe_error("http", "invalid_json", status=status) from error headers_obj = response.headers except urllib.error.HTTPError as error: status = error.code - raw = error.read().decode("utf-8") - body = json.loads(raw) if raw else None + raw = error.read(1_048_577) + if len(raw) > 1_048_576: + raise safe_error("http", "response_too_large", status=status) + try: + body = json.loads(raw.decode("utf-8")) if raw else None + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as parse_error: + raise safe_error("http", "invalid_json", status=status) from parse_error headers_obj = error.headers + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise safe_error("http", "request_failed") from error if status not in expected: - raise SmokeError(f"{method} {url} returned {status}: {body}") + raise safe_error("http", "unexpected_status", status=status) return JsonResponse(status=status, headers=headers_obj, body=body) @@ -183,14 +216,16 @@ def resolve_workspace( fallback_workspace_id: str, fallback_workspace_slug: str, ) -> tuple[str, str]: - session = client.request_json("GET", "/api/auth/session").body + session = require_object(client.request_json("GET", "/api/auth/session").body, "workspace") memberships = session.get("memberships") or [] + if not isinstance(memberships, list) or any(not isinstance(item, dict) for item in memberships): + raise safe_error("workspace", "invalid_response") current_workspace_id = session.get("current_workspace_id") or fallback_workspace_id membership = next( ( item for item in memberships - if item.get("workspace", {}).get("id") == current_workspace_id + if isinstance(item.get("workspace"), dict) and item["workspace"].get("id") == current_workspace_id ), None, ) @@ -198,12 +233,14 @@ def resolve_workspace( membership = memberships[0] workspace = membership.get("workspace", {}) if membership else {} + if not isinstance(workspace, dict): + raise safe_error("workspace", "invalid_response") workspace_id = workspace.get("id") or current_workspace_id workspace_slug = workspace.get("slug") or fallback_workspace_slug if not workspace_id: - raise SmokeError("authenticated session does not include a workspace id") + raise safe_error("workspace", "missing_id") if not workspace_slug: - raise SmokeError("authenticated session does not include a workspace slug") + raise safe_error("workspace", "missing_slug") return workspace_id, workspace_slug @@ -212,24 +249,54 @@ def create_operation( workspace_id: str, operation_name: str, internal_upstream: str, -) -> str: +) -> tuple[str, int]: created = client.request_json( "POST", admin_path(workspace_id, "/operations"), build_operation_payload(operation_name, internal_upstream), ).body - return created["operation_id"] + try: + return str(created["operation_id"]), int(created["version"]) + except (KeyError, TypeError, ValueError) as error: + raise safe_error("operation_create", "invalid_response") from error -def publish_operation(client: Client, workspace_id: str, operation_id: str) -> None: - client.request_json( +def run_operation_test( + client: Client, + workspace_id: str, + operation_id: str, + operation_version: int, +) -> None: + result = client.request_json( + "POST", + admin_path(workspace_id, f"/operations/{operation_id}/test-runs"), + {"version": operation_version, "input": {"probe": "ok"}}, + ).body + if not isinstance(result, dict) or result.get("ok") is not True: + raise safe_error("operation_test", "outcome_not_ok") + + +def publish_operation( + client: Client, + workspace_id: str, + operation_id: str, + operation_version: int, +) -> int: + published = client.request_json( "POST", admin_path(workspace_id, f"/operations/{operation_id}/publish"), - {"version": 1}, - ) + {"version": operation_version}, + ).body + try: + published_version = int(published["published_version"]) + except (KeyError, TypeError, ValueError) as error: + raise safe_error("operation_publish", "invalid_response") from error + if published_version != operation_version: + raise safe_error("operation_publish", "version_mismatch") + return published_version -def create_agent(client: Client, workspace_id: str, agent_slug: str) -> str: +def create_agent(client: Client, workspace_id: str, agent_slug: str) -> tuple[str, int]: created = client.request_json( "POST", admin_path(workspace_id, "/agents"), @@ -241,23 +308,28 @@ def create_agent(client: Client, workspace_id: str, agent_slug: str) -> str: "tool_selection_policy": {}, }, ).body - return created["agent_id"] + try: + return str(created["agent_id"]), int(created["version"]) + except (KeyError, TypeError, ValueError) as error: + raise safe_error("agent_create", "invalid_response") from error def bind_and_publish_agent( client: Client, workspace_id: str, agent_id: str, + agent_version: int, operation_id: str, + operation_version: int, tool_name: str, -) -> None: +) -> int: client.request_json( "POST", admin_path(workspace_id, f"/agents/{agent_id}/bindings"), [ { "operation_id": operation_id, - "operation_version": 1, + "operation_version": operation_version, "tool_name": tool_name, "tool_title": "Check internal service health", "tool_description_override": None, @@ -265,11 +337,18 @@ def bind_and_publish_agent( } ], ) - client.request_json( + published = client.request_json( "POST", admin_path(workspace_id, f"/agents/{agent_id}/publish"), - {"version": 1}, - ) + {"version": agent_version}, + ).body + try: + published_version = int(published["published_version"]) + except (KeyError, TypeError, ValueError) as error: + raise safe_error("agent_publish", "invalid_response") from error + if published_version != agent_version: + raise safe_error("agent_publish", "version_mismatch") + return published_version def create_agent_key(client: Client, workspace_id: str, agent_id: str) -> tuple[str, str]: @@ -278,7 +357,18 @@ def create_agent_key(client: Client, workspace_id: str, agent_id: str) -> tuple[ admin_path(workspace_id, f"/agents/{agent_id}/platform-api-keys"), {"name": f"smoke-key-{int(time.time())}", "scopes": ["read", "write"]}, ).body - return created["secret"], created["api_key"]["api_key"]["id"] + try: + secret = created["secret"] + key_id = created["api_key"]["api_key"]["id"] + except (KeyError, TypeError) as error: + raise safe_error("key_create", "invalid_response") from error + if not nonempty_string(secret) or not nonempty_string(key_id): + raise safe_error("key_create", "invalid_response") + return secret, key_id + + +def nonempty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) def cleanup_smoke_assets( @@ -347,7 +437,7 @@ def initialize_mcp_session(client: Client, mcp_url: str, api_key: str) -> str: ) session_id = initialized.headers.get("MCP-Session-Id") if not session_id: - raise SmokeError("MCP initialize response did not include MCP-Session-Id") + raise safe_error("mcp_initialize", "missing_session_id") client.request_json( "POST", @@ -368,6 +458,20 @@ def initialize_mcp_session(client: Client, mcp_url: str, api_key: str) -> str: return session_id +def terminate_mcp_session(client: Client, mcp_url: str, api_key: str, session_id: str) -> None: + client.request_json( + "DELETE", + mcp_url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + "MCP-Session-Id": session_id, + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + }, + expected=(204, 404), + ) + + def call_mcp( client: Client, mcp_url: str, @@ -388,13 +492,50 @@ def call_mcp( ).body +def validate_tools_list(value: Any, expected_name: str) -> None: + payload = require_object(value, "mcp_tools_list") + result = payload.get("result") + tools = result.get("tools") if isinstance(result, dict) else None + if not isinstance(tools, list) or any(not isinstance(tool, dict) or not nonempty_string(tool.get("name")) for tool in tools): + raise safe_error("mcp_tools_list", "invalid_response") + if expected_name not in [tool["name"] for tool in tools]: + raise safe_error("mcp_tools_list", "published_tool_missing") + + +def validate_tool_call_result(value: Any) -> None: + payload = require_object(value, "mcp_tools_call") + result = payload.get("result") + if "error" in payload or not isinstance(result, dict) or result.get("isError") is not False: + raise safe_error("mcp_tools_call", "outcome_not_ok") + structured = result.get("structuredContent") + if not isinstance(structured, dict) or structured.get("status") != "ok": + raise safe_error("mcp_tools_call", "unexpected_output") + + +def build_safe_summary( + operation_id: str, + operation_version: int, + agent_id: str, + agent_revision: int, +) -> dict[str, Any]: + return { + "agent_id": agent_id[:128], + "agent_revision": agent_revision, + "command_id": "authenticated-product-smoke", + "operation_id": operation_id[:128], + "operation_version": operation_version, + "stages": ["admin_test", "operation_publish", "agent_publish", "mcp_list", "mcp_call"], + "verdict": "pass", + } + + def run(args: argparse.Namespace) -> None: admin_email = os.environ.get("CRANK_STAGING_ADMIN_EMAIL") admin_password = os.environ.get("CRANK_STAGING_ADMIN_PASSWORD") if not admin_email: - raise SmokeError("CRANK_STAGING_ADMIN_EMAIL is required") + raise safe_error("configuration", "missing_admin_email") if not admin_password: - raise SmokeError("CRANK_STAGING_ADMIN_PASSWORD is required") + raise safe_error("configuration", "missing_admin_password") timestamp = int(time.time()) operation_name = f"internal_health_smoke_{timestamp}" @@ -403,8 +544,11 @@ def run(args: argparse.Namespace) -> None: operation_id = None agent_id = None key_id = None + api_key = None + mcp_url = None + session_id = None - print(f"authenticated product smoke: {args.base_url.rstrip('/')}") + print("authenticated product smoke: started") login(client, admin_email, admin_password) print("login: ok") workspace_id, workspace_slug = resolve_workspace( @@ -414,49 +558,80 @@ def run(args: argparse.Namespace) -> None: ) print(f"workspace: {workspace_id} / {workspace_slug}") - operation_id = create_operation( - client, - workspace_id, - operation_name, - args.internal_upstream, - ) - print(f"operation created: {operation_id}") - publish_operation(client, workspace_id, operation_id) - print("operation published: v1") + try: + operation_id, operation_version = create_operation( + client, + workspace_id, + operation_name, + args.internal_upstream, + ) + print(f"operation created: {operation_id} version={operation_version}") + run_operation_test(client, workspace_id, operation_id, operation_version) + print("operation test: ok") + published_operation_version = publish_operation( + client, workspace_id, operation_id, operation_version + ) + print(f"operation published: version={published_operation_version}") - agent_id = create_agent(client, workspace_id, agent_slug) - bind_and_publish_agent(client, workspace_id, agent_id, operation_id, operation_name) - print(f"agent published: {agent_id}") + agent_id, agent_version = create_agent(client, workspace_id, agent_slug) + published_agent_version = bind_and_publish_agent( + client, + workspace_id, + agent_id, + agent_version, + operation_id, + published_operation_version, + operation_name, + ) + print(f"agent published: {agent_id} revision={published_agent_version}") - api_key, key_id = create_agent_key(client, workspace_id, agent_id) - mcp_url = agent_mcp_url(args.base_url, workspace_slug, agent_slug) - session_id = initialize_mcp_session(client, mcp_url, api_key) - print("mcp initialized: ok") + api_key, key_id = create_agent_key(client, workspace_id, agent_id) + mcp_url = agent_mcp_url( + args.mcp_base_url or args.base_url, + workspace_slug, + agent_slug, + args.mcp_path_prefix, + ) + session_id = initialize_mcp_session(client, mcp_url, api_key) + print("mcp initialized: ok") - tools = call_mcp( - client, - mcp_url, - api_key, - session_id, - {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, - ) - tool_names = [tool["name"] for tool in tools.get("result", {}).get("tools", [])] - if operation_name not in tool_names: - raise SmokeError(f"published tool {operation_name} not found in tools/list: {tool_names}") - print("tools/list: ok") + tools = call_mcp( + client, + mcp_url, + api_key, + session_id, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ) + validate_tools_list(tools, operation_name) + print("tools/list: ok") - result = call_mcp( - client, - mcp_url, - api_key, - session_id, - tools_call_payload(operation_name, {"probe": "ok"}), - ) - if "error" in result: - raise SmokeError(f"tools/call returned error: {result['error']}") - print("tools/call: ok") - cleanup_smoke_assets(client, workspace_id, operation_id, agent_id, key_id) - print("authenticated product smoke completed") + result = call_mcp( + client, + mcp_url, + api_key, + session_id, + tools_call_payload(operation_name, {"probe": "ok"}), + ) + validate_tool_call_result(result) + print("tools/call: ok") + summary = build_safe_summary( + operation_id, published_operation_version, agent_id, published_agent_version + ) + if args.summary_output: + try: + Path(args.summary_output).write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + except OSError as error: + raise safe_error("summary", "write_failed") from error + print("authenticated product smoke completed") + finally: + if mcp_url and api_key and session_id: + try: + terminate_mcp_session(client, mcp_url, api_key, session_id) + except SmokeError as error: + print(f"cleanup warning: session termination failed: {error}") + cleanup_smoke_assets(client, workspace_id, operation_id, agent_id, key_id) def parse_args() -> argparse.Namespace: @@ -467,6 +642,16 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--workspace-id", default=DEFAULT_WORKSPACE_ID) parser.add_argument("--workspace-slug", default=DEFAULT_WORKSPACE_SLUG) parser.add_argument("--internal-upstream", default=DEFAULT_INTERNAL_UPSTREAM) + parser.add_argument( + "--mcp-base-url", + help="Optional direct MCP base URL for a local split-port stack.", + ) + parser.add_argument( + "--mcp-path-prefix", + default="/mcp", + help="MCP proxy prefix; use an empty value for a direct MCP listener.", + ) + parser.add_argument("--summary-output") parser.add_argument( "--timeout-seconds", type=int, diff --git a/scripts/check-community-scope.py b/scripts/check-community-scope.py index 7f79dac..9670a43 100755 --- a/scripts/check-community-scope.py +++ b/scripts/check-community-scope.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 import argparse +import io +import os import re import subprocess import sys -from pathlib import Path +from pathlib import Path, PurePosixPath DEFAULT_EXCLUDED_PATHS = { @@ -26,7 +28,14 @@ ALLOW_DIRECTIVE = re.compile( ) FORBIDDEN_PATTERNS = [ - ("enterprise", re.compile(r"\benterprise\b", re.IGNORECASE)), + ( + "multi-workspace", + re.compile(r"\bmulti[-_ ]workspace\b", re.IGNORECASE), + ), + ( + "enterprise", + re.compile(r"\benterprise\b|\benterprise_rbac\b", re.IGNORECASE), + ), ("cloud", re.compile(r"\bcloud\b", re.IGNORECASE)), ("commercial", re.compile(r"\bcommercial\b|коммер", re.IGNORECASE)), ("cloud-russian", re.compile(r"облач", re.IGNORECASE)), @@ -47,8 +56,26 @@ FORBIDDEN_PATTERNS = [ ("machine-token", re.compile(r"machine token", re.IGNORECASE)), ("token-issuer", re.compile(r"token issuer", re.IGNORECASE)), ("request-variables", re.compile(r"request\.variables", re.IGNORECASE)), + ( + "non-rest-upstream", + re.compile(r"\bnon[-_ ]rest[-_ ]upstream\b", re.IGNORECASE), + ), + ( + "distributed-load-targets", + re.compile(r"\barbitrary[-_ ]distributed[-_ ]load[-_ ]targets\b", re.IGNORECASE), + ), ] +MAX_PATH_LENGTH = 1024 +MAX_FINDINGS = 1000 +MAX_REPORT_BYTES = 65_536 +BINARY_PROBE_BYTES = 4096 +MAX_LINE_CHARACTERS = 65_536 +MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024 +MAX_TOTAL_TEXT_BYTES = 256 * 1024 * 1024 +MAX_FILES = 100_000 +MAX_FILE_LIST_BYTES = 16 * 1024 * 1024 + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -69,15 +96,33 @@ def parse_args() -> argparse.Namespace: def git_tracked_files(root: Path) -> list[str]: - result = subprocess.run( - ["git", "ls-files"], + process = subprocess.Popen( + ["git", "ls-files", "--cached", "-z"], cwd=root, - check=True, - text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.DEVNULL, ) - return [line for line in result.stdout.splitlines() if line] + if process.stdout is None: + process.kill() + process.wait() + raise OSError("git file discovery has no output stream") + output = process.stdout.read(MAX_FILE_LIST_BYTES + 1) + if len(output) > MAX_FILE_LIST_BYTES: + process.kill() + process.wait() + raise ValueError("tracked file list exceeds limit") + return_code = process.wait() + if return_code != 0: + raise subprocess.CalledProcessError(return_code, process.args) + discovered = [path.decode("utf-8") for path in output.split(b"\0") if path] + # A tracked deletion has no content to inspect and is a valid worktree state. + # Existing and broken symlink entries remain in the list so path validation + # can reject them fail-closed. + return [ + path + for path in discovered + if (root / path).exists() or (root / path).is_symlink() + ] def should_skip_path(path: str) -> bool: @@ -87,54 +132,173 @@ def should_skip_path(path: str) -> bool: def is_binary(data: bytes) -> bool: - return b"\0" in data[:4096] + return b"\0" in data[:BINARY_PROBE_BYTES] -def scan_file(root: Path, relative_path: str) -> list[str]: +def path_has_symlink(root: Path, logical: PurePosixPath) -> bool: + current = root + for part in logical.parts: + current = current / part + if current.is_symlink(): + return True + return False + + +def resolve_scannable_file(root: Path, relative_path: str) -> Path | None: + if ( + not relative_path + or len(relative_path) > MAX_PATH_LENGTH + or "\\" in relative_path + or any(ord(character) < 32 or ord(character) == 127 for character in relative_path) + ): + return None + logical = PurePosixPath(relative_path) + if logical.is_absolute() or any(part in {"", ".", ".."} for part in logical.parts): + return None + if str(logical) != relative_path or path_has_symlink(root, logical): + return None + candidate = root.joinpath(*logical.parts) + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (FileNotFoundError, OSError, RuntimeError, ValueError): + return None + return resolved if resolved.is_file() else None + + +def normalized_identifier_text(line: str) -> str: + with_word_boundaries = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", line) + return re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", with_word_boundaries) + + +def scan_file( + root: Path, + relative_path: str, + remaining_text_bytes: int = MAX_TOTAL_TEXT_BYTES, +) -> tuple[list[str], bool, bool, int]: + path = resolve_scannable_file(root, relative_path) + if path is None: + return [], False, False, 0 + if should_skip_path(relative_path): - return [] + return [], True, False, 0 - path = root / relative_path - if not path.is_file(): - return [] - - data = path.read_bytes() - if is_binary(data): - return [] - - text = data.decode("utf-8", errors="replace") findings: list[str] = [] - for line_no, line in enumerate(text.splitlines(), start=1): - directive = ALLOW_DIRECTIVE.search(line) - allowed_markers = ( - {label.lower() for label in directive.group(1).split(",")} - if directive - else set() - ) - for label, pattern in FORBIDDEN_PATTERNS: - if label in allowed_markers: - continue - if pattern.search(line): - findings.append(f"{relative_path}:{line_no}: forbidden marker `{label}`") - return findings + scanned_bytes = 0 + try: + with path.open("rb") as binary_file: + if is_binary(binary_file.read(BINARY_PROBE_BYTES)): + return [], True, False, 0 + file_size = os.fstat(binary_file.fileno()).st_size + if file_size > MAX_TEXT_FILE_BYTES or file_size > remaining_text_bytes: + return [], False, False, 0 + binary_file.seek(0) + with io.TextIOWrapper(binary_file, encoding="utf-8", errors="strict") as text_file: + line_no = 0 + while True: + line = text_file.readline(MAX_LINE_CHARACTERS + 1) + if not line: + break + line_no += 1 + if len(line) > MAX_LINE_CHARACTERS: + return findings, False, False, scanned_bytes + if len(line) == MAX_LINE_CHARACTERS and not line.endswith("\n"): + if text_file.read(1): + return findings, False, False, scanned_bytes + + scanned_bytes += len(line.encode("utf-8")) + if ( + scanned_bytes > MAX_TEXT_FILE_BYTES + or scanned_bytes > remaining_text_bytes + ): + return findings, False, False, scanned_bytes + + directive = ALLOW_DIRECTIVE.search(line) + allowed_markers = ( + {label.lower() for label in directive.group(1).split(",")} + if directive + else set() + ) + normalized_line = normalized_identifier_text(line) + for label, pattern in FORBIDDEN_PATTERNS: + if label in allowed_markers: + continue + if pattern.search(line) or pattern.search(normalized_line): + if len(findings) >= MAX_FINDINGS: + return findings, True, True, scanned_bytes + findings.append( + f"{relative_path}:{line_no}: forbidden marker `{label}`" + ) + except (OSError, UnicodeDecodeError): + return findings, False, False, scanned_bytes + return findings, True, False, scanned_bytes + + +def render_failure(findings: list[str], invalid_count: int, input_truncated: bool) -> str: + lines = ["Community scope check failed:"] + selected_findings = findings[:MAX_FINDINGS] + remaining = MAX_FINDINGS - len(selected_findings) + selected_invalid = min(invalid_count, remaining) + lines.extend(f"error: {finding}" for finding in selected_findings) + lines.extend("error: invalid explicit file path" for _ in range(selected_invalid)) + if len(findings) + invalid_count > MAX_FINDINGS or input_truncated: + lines.append("error: findings truncated") + marker = "error: report truncated\n" + report = "\n".join(lines) + "\n" + if len(report.encode("utf-8")) <= MAX_REPORT_BYTES: + return report + budget = MAX_REPORT_BYTES - len(marker.encode("utf-8")) + kept: list[str] = [] + used = 0 + for line in lines: + encoded = (line + "\n").encode("utf-8") + if used + len(encoded) > budget: + break + kept.append(line) + used += len(encoded) + return "\n".join(kept) + ("\n" if kept else "") + marker def main() -> int: args = parse_args() root = args.root.resolve() - files = args.files if args.files is not None else git_tracked_files(root) + try: + files = args.files if args.files is not None else git_tracked_files(root) + except (OSError, subprocess.SubprocessError, UnicodeDecodeError, ValueError): + sys.stderr.write(render_failure([], 1, True)) + return 1 findings: list[str] = [] + invalid_count = 0 + input_truncated = False + scanned_text_bytes = 0 - for relative_path in sorted(files): - findings.extend(scan_file(root, relative_path)) - - if findings: - print("Community scope check failed:", file=sys.stderr) - for finding in findings: - print(f"error: {finding}", file=sys.stderr) + if len(files) > MAX_FILES: + sys.stderr.write(render_failure([], 1, True)) return 1 - print(f"Community scope check passed ({len(files)} tracked files scanned)") + sorted_files = sorted(files) + for index, relative_path in enumerate(sorted_files): + file_findings, valid, file_truncated, file_bytes = scan_file( + root, + relative_path, + MAX_TOTAL_TEXT_BYTES - scanned_text_bytes, + ) + scanned_text_bytes += file_bytes + if not valid: + invalid_count += 1 + findings.extend(file_findings) + if file_truncated: + input_truncated = True + break + if len(findings) + invalid_count >= MAX_FINDINGS: + input_truncated = index + 1 < len(sorted_files) + break + + if findings or invalid_count: + sys.stderr.write(render_failure(findings, invalid_count, input_truncated)) + return 1 + + print(f"Community scope check passed ({len(files)} files scanned)") return 0 diff --git a/scripts/check-config-boundaries.py b/scripts/check-config-boundaries.py new file mode 100644 index 0000000..830a065 --- /dev/null +++ b/scripts/check-config-boundaries.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Reject production environment readers outside the crank-config leaf crate.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +MAX_FILE_BYTES = 1024 * 1024 +IDENTIFIER = re.compile(r"(?:r#)?[A-Za-z_][A-Za-z0-9_]*") +ALLOWED_ENV_MACRO = ("env", "!", "(", '"CARGO_PKG_VERSION"', ")") + + +class BoundaryError(Exception): + pass + + +def resolve_file(root: Path, supplied: str) -> Path: + logical = Path(supplied) + if logical.is_absolute() or ".." in logical.parts: + raise BoundaryError("explicit path must be repository-relative") + path = root / logical + if path.is_symlink() or not path.is_file(): + raise BoundaryError("explicit path must be a regular non-symlink file") + try: + path.resolve().relative_to(root) + except ValueError as error: + raise BoundaryError("explicit path escapes repository") from error + return path + + +def default_files(root: Path) -> list[Path]: + files: list[Path] = [] + for base in (root / "apps", root / "crates"): + if not base.exists(): + continue + for path in base.rglob("*.rs"): + relative_parts = path.relative_to(root).parts + if "crank-config" in relative_parts or "tests" in relative_parts: + continue + if path.is_symlink(): + raise BoundaryError("production Rust source must not be a symlink") + if not path.is_file(): + raise BoundaryError("production Rust source must be a regular file") + files.append(path) + return sorted(files) + + +def tokenize(text: str) -> list[tuple[str, int]]: + """Return Rust-like tokens while discarding comments and preserving literals.""" + tokens: list[tuple[str, int]] = [] + index = 0 + line = 1 + block_depth = 0 + while index < len(text): + if block_depth: + if text.startswith("/*", index): + block_depth += 1 + index += 2 + elif text.startswith("*/", index): + block_depth -= 1 + index += 2 + else: + line += text[index] == "\n" + index += 1 + continue + if text.startswith("//", index): + newline = text.find("\n", index + 2) + if newline < 0: + break + index = newline + continue + if text.startswith("/*", index): + block_depth = 1 + index += 2 + continue + character = text[index] + if character.isspace(): + line += character == "\n" + index += 1 + continue + token_line = line + char_match = re.match(r"'(?:\\.|[^\\'\n])'", text[index:]) + if char_match: + tokens.append((char_match.group(), token_line)) + index += char_match.end() + continue + raw_match = re.match(r'r(#{0,255})"', text[index:]) + if raw_match: + terminator = '"' + raw_match.group(1) + end = text.find(terminator, index + raw_match.end()) + if end < 0: + raise BoundaryError("unterminated raw string literal") + end += len(terminator) + token = text[index:end] + tokens.append((token, token_line)) + line += token.count("\n") + index = end + continue + if character == '"': + end = index + 1 + escaped = False + while end < len(text): + current = text[end] + if current == '"' and not escaped: + end += 1 + break + escaped = current == "\\" and not escaped + if current != "\\": + escaped = False + line += current == "\n" + end += 1 + else: + raise BoundaryError("unterminated string literal") + tokens.append((text[index:end], token_line)) + index = end + continue + match = IDENTIFIER.match(text, index) + if match: + tokens.append((match.group(), token_line)) + index = match.end() + continue + if text.startswith("::", index): + tokens.append(("::", token_line)) + index += 2 + continue + tokens.append((character, token_line)) + index += 1 + if block_depth: + raise BoundaryError("unterminated block comment") + return tokens + + +def scan(path: Path) -> list[tuple[int, int]]: + data = path.read_bytes() + if len(data) > MAX_FILE_BYTES: + raise BoundaryError("Rust source exceeds boundary scanner size limit") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + raise BoundaryError("Rust source is not valid UTF-8") from error + tokens = tokenize(text) + values = [token for token, _ in tokens] + findings: set[tuple[int, int]] = set() + std_aliases: set[str] = set() + for index in range(len(tokens)): + window = values[index : index + 5] + line = tokens[index][1] + if ( + len(window) >= 5 + and window[:4] == ["std", "::", "env", "::"] + and window[4] in {"var", "vars", "var_os", "vars_os"} + ): + findings.add((line, 1)) + if len(window) >= 3 and window[0] == "env" and window[1] == "::" and window[2] in { + "var", + "vars", + "var_os", + "vars_os", + }: + findings.add((line, 2)) + if values[index].removeprefix("r#").endswith("_from_env"): + findings.add((line, 3)) + if len(window) >= 2 and window[0] in {"dotenv", "dotenvy"} and window[1] == "::": + findings.add((line, 4)) + if len(window) >= 5 and window[0] in {"env", "option_env"} and window[1] == "!": + if tuple(window) != ALLOWED_ENV_MACRO: + findings.add((line, 5)) + if ( + len(window) >= 4 + and window[0] in {"use", "crate"} + and window[1] == "std" + and window[2] == "as" + ): + std_aliases.add(window[3]) + grouped = values[index : index + 8] + if ( + len(grouped) == 8 + and grouped[:6] == ["use", "std", "::", "{", "self", "as"] + and IDENTIFIER.fullmatch(grouped[6]) + and grouped[7] in {"}", ","} + ): + std_aliases.add(grouped[6]) + if len(window) >= 5 and window[:3] == ["extern", "crate", "std"] and window[3] == "as": + std_aliases.add(window[4]) + if window[:3] == ["use", "std", "::"]: + end = index + 3 + while end < len(tokens) and values[end] != ";": + if values[end] == "env": + findings.add((line, 1)) + break + end += 1 + for index, (token, line) in enumerate(tokens): + if token in std_aliases and values[index : index + 3] == [token, "::", "env"]: + findings.add((line, 6)) + return sorted(findings) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--files", nargs="*") + args = parser.parse_args() + root = args.root.resolve() + try: + files = ( + [resolve_file(root, item) for item in args.files] + if args.files is not None + else default_files(root) + ) + violations: list[tuple[str, int, int]] = [] + for path in files: + if "crank-config" in path.relative_to(root).parts: + continue + for line, pattern in scan(path): + violations.append((path.relative_to(root).as_posix(), line, pattern)) + except (BoundaryError, OSError) as error: + print(f"config boundary check failed: {error}", file=sys.stderr) + return 1 + if violations: + for path, line, pattern in sorted(violations): + print( + f"config boundary violation: {path}:{line} pattern={pattern}", + file=sys.stderr, + ) + return 1 + print(f"config boundary check passed: files={len(files)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-migration-boundaries.py b/scripts/check-migration-boundaries.py new file mode 100644 index 0000000..f8d5346 --- /dev/null +++ b/scripts/check-migration-boundaries.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Fail closed when PostgreSQL migration authority escapes its canonical module.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +MAX_FILE_BYTES = 2 * 1024 * 1024 +DDL = re.compile( + r"(?is)\b(?:create|alter|drop|truncate|comment\s+on|grant|revoke)\s+" + r"(?:(?:or\s+replace|unique|temporary|temp|unlogged)\s+)*" + r"(?:table|index|schema|view|materialized\s+view|type|sequence|function|procedure|" + r"trigger|extension|domain|policy|role)\b" +) +RUNNER = re.compile( + r"(?is)pg_advisory_(?:xact_)?lock|sqlx\s*::\s*migrate|_sqlx_migrations|" + r"include_(?:str|bytes)!\s*\([^)]*\.sql|" + r"(?:insert\s+into|update|delete\s+from)\s+__crank_(?:core_|mcp_|ext_)?migrations" +) + + +def canonical(path: Path, root: Path) -> bool: + relative = path.relative_to(root).as_posix() + return relative == "crates/crank-registry/src/migrations.rs" or relative.startswith( + "crates/crank-registry/src/migrations/" + ) + + +def candidates(root: Path) -> list[Path]: + result: list[Path] = [] + for base_name in ("apps", "crates"): + base = root / base_name + if not base.exists(): + continue + for path in base.rglob("*"): + if path.suffix not in {".rs", ".sql"}: + continue + if "crank-test-support" in path.relative_to(root).parts: + continue + if path.is_symlink() or not path.is_file(): + raise ValueError("production Rust/SQL path must be a regular non-symlink file") + if "/tests/" in f"/{path.relative_to(root).as_posix()}/": + continue + result.append(path) + return sorted(result) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(".")) + args = parser.parse_args() + root = args.root.resolve() + try: + files = candidates(root) + violations: list[str] = [] + for path in files: + if canonical(path, root): + continue + data = path.read_bytes() + if len(data) > MAX_FILE_BYTES: + raise ValueError("production Rust/SQL file exceeds scanner limit") + text = data.decode("utf-8") + if DDL.search(text) or RUNNER.search(text): + violations.append(path.relative_to(root).as_posix()) + except (OSError, UnicodeDecodeError, ValueError) as error: + print(f"migration boundary check failed: {error}", file=sys.stderr) + return 1 + if violations: + for path in violations: + print(f"migration boundary violation: {path}", file=sys.stderr) + return 1 + print(f"migration boundary check passed: files={len(files)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-runtime-config.py b/scripts/check-runtime-config.py new file mode 100644 index 0000000..8dd13de --- /dev/null +++ b/scripts/check-runtime-config.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Fail-closed drift check for the generated runtime configuration contract.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +MAX_INPUT_BYTES = 4 * 1024 * 1024 +OWNED_PREFIXES = ("CRANK_", "POSTGRES_", "OTEL_") +ENV_FILES = ( + ".env.example", + "deploy/community/.env.example", + "deploy/community/.env.images.example", +) +COMPOSE_FILES = ( + "docker-compose.yml", + "deploy/community/docker-compose.yml", + "deploy/community/docker-compose.images.yml", +) +BEGIN = "# BEGIN GENERATED CRANK RUNTIME CONFIG" +END = "# END GENERATED CRANK RUNTIME CONFIG" +ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$") +INTERPOLATION_OPERATORS = (":-", ":?", ":+", "-", "?", "+") + + +class ContractError(Exception): + pass + + +def reject_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ContractError("duplicate JSON key") + result[key] = value + return result + + +def read_text(path: Path) -> str: + if not path.is_file() or path.is_symlink(): + raise ContractError(f"missing regular file: {path.name}") + data = path.read_bytes() + if len(data) > MAX_INPUT_BYTES: + raise ContractError(f"input too large: {path.name}") + try: + return data.decode("utf-8") + except UnicodeDecodeError as error: + raise ContractError(f"invalid UTF-8: {path.name}") from error + + +def load_contract( + root: Path, +) -> tuple[set[str], dict[str, dict[str, object]], set[str]]: + path = root / "docs/schemas/runtime-config.schema.json" + try: + payload = json.loads(read_text(path), object_pairs_hook=reject_duplicates) + except (json.JSONDecodeError, ValueError, RecursionError) as error: + raise ContractError("invalid runtime configuration contract JSON") from error + fields = payload.get("fields") + if payload.get("schema_version") != 1 or not isinstance(fields, list): + raise ContractError("invalid runtime configuration contract shape") + effective: set[str] = set() + specifications: dict[str, dict[str, object]] = {} + for field in fields: + if not isinstance(field, dict): + raise ContractError("invalid field entry") + name = field.get("env_name") + scope = field.get("process") + mode = field.get("mode") + if not isinstance(name, str) or scope not in {"shared", "admin_api", "mcp_server"}: + raise ContractError("invalid field identity") + if name in specifications: + raise ContractError("duplicate runtime field") + required = field.get("required") + sensitivity = field.get("sensitivity") + default = field.get("default") + if not isinstance(required, bool) or sensitivity not in { + "public", + "internal", + "secret", + }: + raise ContractError("invalid field semantics") + if default is not None and not isinstance(default, str): + raise ContractError("invalid field default") + specifications[name] = field + if mode == "effective": + effective.add(name) + elif mode != "deprecated_no_effect": + raise ContractError("unknown runtime field mode") + deployment = payload.get("deployment_only_fields") + if ( + not isinstance(deployment, list) + or not all(isinstance(name, str) and ENV_NAME.fullmatch(name) for name in deployment) + or len(set(deployment)) != len(deployment) + ): + raise ContractError("invalid deployment-only fields") + return effective, specifications, set(deployment) + + +def env_entries(content: str) -> dict[str, str]: + if content.count(BEGIN) != 1 or content.count(END) != 1: + raise ContractError("generated environment markers are missing or duplicated") + section = content.split(BEGIN, 1)[1].split(END, 1)[0] + entries: dict[str, str] = {} + for line in section.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + name, separator, _ = line.partition("=") + if not separator or not ENV_NAME.fullmatch(name) or name in entries: + raise ContractError("invalid or duplicate generated environment entry") + entries[name] = line.partition("=")[2] + return entries + + +def interpolations(content: str) -> list[tuple[str, str, str]]: + """Parse bounded, non-nested Compose ${NAMEpayload} expressions.""" + results: list[tuple[str, str, str]] = [] + cursor = 0 + while True: + start = content.find("${", cursor) + if start < 0: + return results + end = content.find("}", start + 2) + if end < 0: + raise ContractError("unterminated Compose interpolation") + expression = content[start + 2 : end] + if "${" in expression or len(expression) > 8192: + raise ContractError("invalid nested or oversized Compose interpolation") + name_end = 0 + while name_end < len(expression) and ( + expression[name_end].isalnum() or expression[name_end] == "_" + ): + name_end += 1 + name = expression[:name_end] + remainder = expression[name_end:] + if not ENV_NAME.fullmatch(name): + raise ContractError("invalid Compose interpolation name") + operator = "" + payload = "" + if remainder: + operator = next( + (candidate for candidate in INTERPOLATION_OPERATORS if remainder.startswith(candidate)), + "", + ) + if not operator: + raise ContractError("invalid Compose interpolation operator") + payload = remainder[len(operator) :] + results.append((name, operator, payload)) + cursor = end + 1 + + +def parse_runtime_reference(value: str) -> tuple[str, str, str]: + references = interpolations(value) + if len(references) != 1 or value.strip() != "${" + "".join(references[0]) + "}": + raise ContractError("runtime Compose value must be one direct interpolation") + return references[0] + + +def compose_environment(content: str, service: str) -> dict[str, str]: + lines = content.splitlines() + in_service = False + in_environment = False + entries: dict[str, str] = {} + for line in lines: + if line == f" {service}:": + in_service = True + in_environment = False + continue + if in_service and line.startswith(" ") and not line.startswith(" "): + break + if in_service and line == " environment:": + in_environment = True + continue + if in_environment: + if not line.startswith(" "): + break + stripped = line.strip() + name, separator, _ = stripped.partition(":") + if separator and name.startswith(OWNED_PREFIXES): + if name in entries: + raise ContractError("duplicate Compose runtime field") + entries[name] = stripped.partition(":")[2].strip() + return entries + + +def validate_runtime_reference( + name: str, + value: str, + specification: dict[str, object], + declared_values: set[str], +) -> None: + reference, operator, payload = parse_runtime_reference(value) + if reference != name: + raise ContractError(f"wrong Compose interpolation reference: {name}") + required = specification["required"] + sensitivity = specification["sensitivity"] + if required: + if operator not in {"", ":?"} or (operator == ":?" and not payload): + raise ContractError(f"required runtime field has fallback: {name}") + return + if operator != ":-": + raise ContractError(f"optional runtime field must use empty-aware default: {name}") + if sensitivity != "secret" and payload not in declared_values: + raise ContractError(f"divergent Compose inline default: {name}") + + +def validate(root: Path) -> None: + effective, specifications, deployment = load_contract(root) + declared_values: dict[str, set[str]] = {name: set() for name in effective} + for relative in ENV_FILES: + entries = env_entries(read_text(root / relative)) + if set(entries) != effective: + raise ContractError(f"generated environment drift: {Path(relative).name}") + for name, value in entries.items(): + declared_values[name].add(value) + for name in effective: + default = specifications[name].get("default") + if isinstance(default, str): + declared_values[name].add(default) + + for relative in COMPOSE_FILES: + content = read_text(root / relative) + known = effective | deployment + for name, _, _ in interpolations(content): + if name not in known: + raise ContractError(f"unknown Compose interpolation: {name}") + admin = compose_environment(content, "admin-api") + mcp = compose_environment(content, "mcp-server") + required_admin = { + name + for name in effective + if specifications[name]["process"] in {"shared", "admin_api"} + } + required_mcp = { + name + for name in effective + if specifications[name]["process"] in {"shared", "mcp_server"} + } + if set(admin) != required_admin: + raise ContractError(f"admin Compose runtime drift: {Path(relative).name}") + if set(mcp) != required_mcp: + raise ContractError(f"MCP Compose runtime drift: {Path(relative).name}") + for entries in (admin, mcp): + for name, value in entries.items(): + validate_runtime_reference( + name, + value, + specifications[name], + declared_values[name], + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(".")) + args = parser.parse_args() + try: + validate(args.root.resolve()) + except (ContractError, OSError) as error: + print(f"runtime configuration contract check failed: {error}", file=sys.stderr) + return 1 + print("runtime configuration contract check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-rust-boundaries.py b/scripts/check-rust-boundaries.py index 89d1336..748233d 100755 --- a/scripts/check-rust-boundaries.py +++ b/scripts/check-rust-boundaries.py @@ -51,6 +51,8 @@ def package_category(name: str, manifest_path: Path, workspace_root: Path) -> st return "core" if name == "crank-metrics": return "metrics" + if name == "crank-config": + return "config" if name == "crank-observability": return "observability" if name == "crank-registry": @@ -123,6 +125,9 @@ def boundary_reason(source: Package, dependency: Package) -> str | None: if source.category == "metrics": return "crank-metrics must not depend on other workspace crates" + if source.category == "config": + return "crank-config must not depend on other workspace crates" + if source.category == "observability" and dependency.category != "metrics": return "crank-observability must not depend on other workspace crates" diff --git a/scripts/check-rust-module-boundaries.sh b/scripts/check-rust-module-boundaries.sh index 91dcec6..f1382ce 100755 --- a/scripts/check-rust-module-boundaries.sh +++ b/scripts/check-rust-module-boundaries.sh @@ -20,6 +20,9 @@ check_no_match() { echo "Rust module boundary check: checking module-level imports" +python3 "$ROOT_DIR/scripts/check-config-boundaries.py" --root "$ROOT_DIR" || status=1 +python3 "$ROOT_DIR/scripts/check-migration-boundaries.py" --root "$ROOT_DIR" || status=1 + check_no_match \ "admin-api service modules must not depend on axum HTTP types" \ '^\s*use\s+axum(::|[;\{])' \ @@ -66,6 +69,8 @@ Rules: - registry remains storage-only and HTTP-client agnostic; - runtime remains execution-only and storage/framework agnostic. - names and labels of metrics remain inside the typed crank-metrics contract. +- process environment is read only by the leaf crank-config adapter. +- PostgreSQL DDL and migration advisory locks are owned only by crank-registry migrations. EOF fi diff --git a/scripts/collect-capability-baseline.py b/scripts/collect-capability-baseline.py new file mode 100644 index 0000000..e3e919b --- /dev/null +++ b/scripts/collect-capability-baseline.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Create bounded, sanitized capability-baseline evidence candidates.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + + +MAX_REPORT_BYTES = 4 * 1024 * 1024 +REVISION_RE = re.compile(r"^[0-9a-f]{40}$") +FLOW_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +ENVIRONMENT_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +COMMAND_IDS = { + "python-tooling-tests", + "rust-admin-integration", + "rust-mcp-integration", + "ui-build", + "ui-playwright", + "just-verify", + "authenticated-product-smoke", +} + + +class CollectionError(Exception): + def __init__(self, code: str, pointer: str) -> None: + super().__init__(code) + self.code = code + self.pointer = pointer + + +def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CollectionError("INVALID_JSON", "/") + result[key] = value + return result + + +def load_report(path: Path) -> tuple[dict[str, Any], str]: + try: + with path.open("rb") as file: + raw = file.read(MAX_REPORT_BYTES + 1) + except OSError as error: + raise CollectionError("BROKEN_REPORT_LINK", "/report") from error + if len(raw) > MAX_REPORT_BYTES: + raise CollectionError("INPUT_TOO_LARGE", "/report") + digest = hashlib.sha256(raw).hexdigest() + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as error: + raise CollectionError("INVALID_JSON", "/report") from error + if not isinstance(value, dict): + raise CollectionError("INVALID_REPORT", "/report") + return value, digest + + +def iter_tests(value: Any): + if isinstance(value, dict): + tests = value.get("tests") + if isinstance(tests, list): + for test in tests: + if isinstance(test, dict): + yield test + for key in ("suites", "specs"): + children = value.get(key) + if isinstance(children, list): + for child in children: + yield from iter_tests(child) + elif isinstance(value, list): + for item in value: + yield from iter_tests(item) + + +def playwright_verdict(report: dict[str, Any]) -> tuple[str, dict[str, int]]: + counts = {"passed": 0, "failed": 0, "flaky": 0, "skipped": 0, "not_run": 0} + report_errors = report.get("errors", []) + if not isinstance(report_errors, list): + counts["failed"] = 1 + return "fail", counts + if report_errors: + counts["failed"] = min(len(report_errors), 10_000) + return "fail", counts + tests = list(iter_tests(report)) + if not tests: + counts["not_run"] = 1 + return "not_run", counts + for test in tests: + status = test.get("status") + results = test.get("results") if isinstance(test.get("results"), list) else [] + result_statuses = [result.get("status") for result in results if isinstance(result, dict)] + retries = [result.get("retry", 0) for result in results if isinstance(result, dict)] + if len(result_statuses) != len(results): + counts["not_run"] += 1 + continue + if status == "flaky" or any(isinstance(retry, int) and retry > 0 for retry in retries): + counts["flaky"] += 1 + elif status == "skipped" or (not results and status in ("skipped", "expected")): + counts["skipped"] += 1 + elif status in ("unexpected", "failed", "timedOut", "interrupted") or any( + result_status in ("failed", "timedOut", "interrupted") for result_status in result_statuses + ): + counts["failed"] += 1 + elif results and all(result_status == "passed" for result_status in result_statuses): + counts["passed"] += 1 + else: + counts["not_run"] += 1 + if counts["failed"]: + return "fail", counts + if counts["flaky"]: + return "flaky", counts + if counts["skipped"]: + return "skipped", counts + if counts["not_run"]: + return "not_run", counts + return "pass", counts + + +def validate_labels(args: argparse.Namespace) -> None: + if not REVISION_RE.fullmatch(args.source_revision): + raise CollectionError("INVALID_ARGUMENT", "/source_revision") + if not ENVIRONMENT_RE.fullmatch(args.environment_class): + raise CollectionError("INVALID_ARGUMENT", "/environment_class") + if not args.flow_id or any(not FLOW_RE.fullmatch(flow_id) for flow_id in args.flow_id): + raise CollectionError("INVALID_ARGUMENT", "/flow_ids") + + +def collect_playwright(args: argparse.Namespace) -> dict[str, Any]: + validate_labels(args) + report, digest = load_report(Path(args.report)) + verdict, counts = playwright_verdict(report) + return { + "accepted": verdict == "pass", + "collector": "capability-baseline-collector-v1", + "command_id": "ui-playwright", + "environment_class": args.environment_class, + "evidence_mode": "automated", + "execution_verdict": verdict, + "flow_ids": sorted(set(args.flow_id)), + "id": f"run-ui-playwright-{digest[:12]}", + "source_report_sha256": digest, + "source_revision": args.source_revision, + "summary": counts, + } + + +def collect_command_report(args: argparse.Namespace) -> dict[str, Any]: + validate_labels(args) + report, digest = load_report(Path(args.report)) + command_id = report.get("command_id") + if command_id not in COMMAND_IDS: + raise CollectionError("UNKNOWN_COMMAND", "/report/command_id") + exit_code = report.get("exit_code") + timed_out = report.get("timed_out") + skipped = report.get("skipped") + if type(exit_code) is not int or not isinstance(timed_out, bool) or type(skipped) is not int or skipped < 0: + raise CollectionError("INVALID_REPORT", "/report") + if timed_out: + verdict = "blocked" + elif exit_code != 0: + verdict = "fail" + elif skipped: + verdict = "skipped" + else: + verdict = "pass" + return { + "accepted": verdict == "pass", + "collector": "capability-baseline-collector-v1", + "command_id": command_id, + "environment_class": args.environment_class, + "evidence_mode": "automated", + "execution_verdict": verdict, + "flow_ids": sorted(set(args.flow_id)), + "id": f"run-{command_id}-{digest[:12]}", + "source_report_sha256": digest, + "source_revision": args.source_revision, + "summary": {"exit_code": exit_code, "skipped": skipped, "timed_out": timed_out}, + } + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + subparsers = root.add_subparsers(dest="mode", required=True) + playwright = subparsers.add_parser("playwright") + playwright.add_argument("--report", required=True) + playwright.add_argument("--output", required=True) + playwright.add_argument("--source-revision", required=True) + playwright.add_argument("--environment-class", required=True) + playwright.add_argument("--flow-id", action="append", required=True) + command = subparsers.add_parser("command-report") + command.add_argument("--report", required=True) + command.add_argument("--output", required=True) + command.add_argument("--source-revision", required=True) + command.add_argument("--environment-class", required=True) + command.add_argument("--flow-id", action="append", required=True) + return root + + +def main() -> int: + args = parser().parse_args() + try: + candidate = collect_playwright(args) if args.mode == "playwright" else collect_command_report(args) + encoded = (json.dumps(candidate, indent=2, sort_keys=True) + "\n").encode("utf-8") + if len(encoded) > 65_536: + raise CollectionError("OUTPUT_TOO_LARGE", "/output") + Path(args.output).write_bytes(encoded) + except CollectionError as error: + print(f"{error.code} pointer={error.pointer}", file=sys.stderr) + return 1 + except OSError: + print("OUTPUT_WRITE_FAILED pointer=/output", file=sys.stderr) + return 1 + print(f"capability baseline candidate: verdict={candidate['execution_verdict']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-capability-baseline.py b/scripts/validate-capability-baseline.py new file mode 100644 index 0000000..c4dbfa1 --- /dev/null +++ b/scripts/validate-capability-baseline.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Validate the versioned Crank Community capability-baseline snapshot.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +MAX_JSON_BYTES = 4 * 1024 * 1024 +MAX_CHECKLIST_BYTES = 1024 * 1024 +MAX_DIAGNOSTICS = 1000 +MAX_REPORT_BYTES = 65_536 +VERSION_RE = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[1-9][0-9]*$") +HEX_RE = re.compile(r"^[0-9a-f]{64}$") +FLOW_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +EXPECTED_KINDS = {"inventory", "required_surfaces", "taxonomy", "checklist", "results"} +IMPLEMENTATION_STATUSES = {"implemented", "planned", "gap", "blocked"} +EXECUTION_VERDICTS = {"pass", "fail", "blocked", "skipped", "flaky", "not_run"} +EVIDENCE_MODES = {"automated", "manual_only"} +SEVERE = {"Critical", "High"} +COMMAND_IDS = { + "python-tooling-tests", "rust-admin-integration", "rust-mcp-integration", + "ui-build", "ui-playwright", "just-verify", "authenticated-product-smoke", +} +CHECKLIST_VERDICTS = {"pass", "fail", "blocked", "not_run", "gap", "n/a"} +CHECKLIST_STATES = {"happy", "loading", "empty", "error", "recovery", "stale", "ru-en", "safe-output"} +UNSAFE_RE = re.compile(r"(?i)(bearer\s+\S+|cookie\s*[:=]|authorization\s*[:=]|https?://|/(?:home|users|root)/)") +UNSUPPORTED_SCHEMA_KEYWORDS = { + "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "contains", + "dependentSchemas", "patternProperties", "propertyNames", "unevaluatedItems", + "unevaluatedProperties", "prefixItems", +} + + +@dataclass(frozen=True, order=True) +class Diagnostic: + code: str + pointer: str + + +class DuplicateKey(ValueError): + pass + + +def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKey(key) + result[key] = value + return result + + +def logical_path(root: Path, value: Any) -> Path | None: + if not isinstance(value, str) or not value or len(value) > 1024 or any(ord(char) < 32 for char in value): + return None + candidate = Path(value) + if candidate.is_absolute() or "\\" in value or any(part in ("", ".", "..") for part in candidate.parts): + return None + try: + root_resolved = root.resolve(strict=True) + joined = root / candidate + if joined.is_symlink(): + return None + resolved = joined.resolve(strict=True) + resolved.relative_to(root_resolved) + except (OSError, ValueError): + return None + if not resolved.is_file(): + return None + return resolved + + +def tracked_path(root: Path, value: Any) -> bool: + if not isinstance(value, str): + return False + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "--error-unmatch", "--", value], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def read_bytes(path: Path, limit: int) -> bytes: + try: + with path.open("rb") as file: + raw = file.read(limit + 1) + except OSError as error: + raise ValueError("BROKEN") from error + if len(raw) > limit: + raise OverflowError + return raw + + +def read_json(path: Path) -> Any: + raw = read_bytes(path, MAX_JSON_BYTES) + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError, DuplicateKey, ValueError, RecursionError) as error: + raise TypeError from error + + +def schema_contract_valid(schema: Any) -> bool: + if not isinstance(schema, dict): + return False + allowed_root = {"$schema", "$id", "title", "description", "type", "additionalProperties", "required", "properties", "$defs"} + if set(schema) - allowed_root: + return False + pending = [schema] + while pending: + node = pending.pop() + if isinstance(node, dict): + if set(node) & UNSUPPORTED_SCHEMA_KEYWORDS: + return False + pending.extend(node.values()) + elif isinstance(node, list): + pending.extend(node) + if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + return False + if schema.get("type") != "object" or schema.get("additionalProperties") is not False: + return False + if schema.get("required") != ["schema_version", "baseline_version", "artifacts"]: + return False + properties = schema.get("properties") + definitions = schema.get("$defs") + if not isinstance(properties, dict): + return False + if not isinstance(definitions, dict) or set(definitions) != {"artifact", "taxonomy", "run", "manual_result", "defect", "results"}: + return False + for name in ("artifact", "taxonomy", "run", "defect", "results"): + definition = definitions.get(name) + if not isinstance(definition, dict) or definition.get("type") != "object" or definition.get("additionalProperties") is not False: + return False + if definitions["run"].get("required") != ["id", "command_id", "flow_ids", "execution_verdict", "evidence_mode", "accepted", "source_report_sha256", "source_revision", "environment_class", "collector"]: + return False + if definitions["defect"].get("required") != ["id", "severity", "steps", "contract", "owner", "flow_ids", "next_action"]: + return False + taxonomy_properties = definitions["taxonomy"].get("properties") + full_pass = taxonomy_properties.get("full_pass") if isinstance(taxonomy_properties, dict) else None + if not isinstance(full_pass, dict) or set(full_pass.get("properties", {})) != {"implementation_status", "execution_verdict", "evidence_mode"}: + return False + manual_result = definitions.get("manual_result") + if not isinstance(manual_result, dict) or manual_result.get("type") != "object" or manual_result.get("additionalProperties") is not False: + return False + version = properties.get("schema_version") + artifacts = properties.get("artifacts") + return ( + isinstance(version, dict) + and type(version.get("const")) is int + and version.get("const") == 1 + and isinstance(artifacts, dict) + and artifacts.get("minItems") == 5 + and artifacts.get("maxItems") == 5 + ) + + +def add(diagnostics: list[Diagnostic], code: str, pointer: str) -> None: + if len(diagnostics) < MAX_DIAGNOSTICS: + diagnostics.append(Diagnostic(code, pointer[:1024])) + + +def exact_keys(value: Any, required: set[str], optional: set[str] = set()) -> bool: + return isinstance(value, dict) and required <= set(value) and not (set(value) - required - optional) + + +def nonempty_text(value: Any, limit: int) -> bool: + return isinstance(value, str) and bool(value.strip()) and len(value) <= limit + + +def parse_checklist(text: Any, diagnostics: list[Diagnostic]) -> dict[str, dict[str, Any]]: + if not isinstance(text, str): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/checklist") + return {} + matches = list(re.finditer(r"(?m)^## (UI-[0-9]{2})\s+[^\n]+\n", text)) + if not 1 <= len(matches) <= 16: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/checklist/checks") + checks: dict[str, dict[str, Any]] = {} + for index, match in enumerate(matches): + check_id = match.group(1) + block = text[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(text)] + fields: dict[str, str] = {} + for key in ("flow_id", "states", "verdict", "reason"): + field = re.search(rf"(?m)^- {key}:\s*(.+?)\s*$", block) + if field: + fields[key] = field.group(1) + if check_id in checks or set(fields) != {"flow_id", "states", "verdict", "reason"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/checklist/{check_id}") + continue + states = [item.strip() for item in fields["states"].split(",") if item.strip()] + if not states or len(states) != len(set(states)) or set(states) - CHECKLIST_STATES: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/checklist/{check_id}/states") + if fields["verdict"] not in CHECKLIST_VERDICTS or not nonempty_text(fields["reason"], 2048): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/checklist/{check_id}/verdict") + checks[check_id] = {"flow_id": fields["flow_id"], "verdict": fields["verdict"]} + return checks + + +def validate(root: Path, manifest_path: str, schema_path: str) -> tuple[list[Diagnostic], str | None, int]: + diagnostics: list[Diagnostic] = [] + manifest_file = logical_path(root, manifest_path) + schema_file = logical_path(root, schema_path) + if manifest_file is None: + add(diagnostics, "BROKEN_ARTIFACT_LINK", "/manifest") + if schema_file is None: + add(diagnostics, "BROKEN_ARTIFACT_LINK", "/schema") + if diagnostics: + return diagnostics, None, 0 + try: + manifest = read_json(manifest_file) # type: ignore[arg-type] + schema = read_json(schema_file) # type: ignore[arg-type] + except OverflowError: + add(diagnostics, "INPUT_TOO_LARGE", "/") + return diagnostics, None, 0 + except TypeError: + add(diagnostics, "INVALID_JSON", "/") + return diagnostics, None, 0 + except ValueError: + add(diagnostics, "BROKEN_ARTIFACT_LINK", "/") + return diagnostics, None, 0 + if not schema_contract_valid(schema): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/schema") + if not isinstance(manifest, dict): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/manifest") + return diagnostics, None, 0 + if set(manifest) != {"schema_version", "baseline_version", "artifacts"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/manifest") + version = manifest.get("baseline_version") + if not isinstance(version, str) or not VERSION_RE.fullmatch(version): + add(diagnostics, "BASELINE_VERSION_MISMATCH", "/baseline_version") + version = None + if type(manifest.get("schema_version")) is not int or manifest.get("schema_version") != 1: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/schema_version") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or len(artifacts) != 5: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/artifacts") + return diagnostics, version, 0 + loaded: dict[str, Any] = {} + seen: set[str] = set() + for index, item in enumerate(artifacts): + pointer = f"/artifacts/{index}" + if not isinstance(item, dict): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", pointer) + continue + if set(item) != {"kind", "path", "sha256"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", pointer) + kind = item.get("kind") + if kind not in EXPECTED_KINDS or kind in seen: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"{pointer}/kind") + continue + seen.add(kind) + artifact_file = logical_path(root, item.get("path")) + if artifact_file is None: + add(diagnostics, "BROKEN_ARTIFACT_LINK", f"{pointer}/path") + continue + if not tracked_path(root, item.get("path")): + add(diagnostics, "UNTRACKED_ARTIFACT", f"{pointer}/path") + expected_hash = item.get("sha256") + if not isinstance(expected_hash, str) or not HEX_RE.fullmatch(expected_hash): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"{pointer}/sha256") + continue + try: + raw = read_bytes(artifact_file, MAX_CHECKLIST_BYTES if kind == "checklist" else MAX_JSON_BYTES) + except OverflowError: + add(diagnostics, "INPUT_TOO_LARGE", pointer) + continue + except ValueError: + add(diagnostics, "BROKEN_ARTIFACT_LINK", pointer) + continue + if hashlib.sha256(raw).hexdigest() != expected_hash: + add(diagnostics, "CHECKSUM_MISMATCH", f"{pointer}/sha256") + if kind == "checklist": + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + add(diagnostics, "INVALID_JSON", pointer) + continue + match = re.search(r"(?m)^baseline_version:\s*([^\s]+)\s*$", text) + if not match or match.group(1) != version: + add(diagnostics, "BASELINE_VERSION_MISMATCH", pointer) + loaded[kind] = text + else: + try: + loaded[kind] = json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError, DuplicateKey, ValueError, RecursionError): + add(diagnostics, "INVALID_JSON", pointer) + if seen != EXPECTED_KINDS: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/artifacts") + checklist_checks = parse_checklist(loaded.get("checklist"), diagnostics) + validate_loaded(loaded, version, checklist_checks, diagnostics) + return diagnostics, version, len(loaded.get("inventory", {}).get("flows", [])) if isinstance(loaded.get("inventory"), dict) else 0 + + +def validate_loaded( + loaded: dict[str, Any], + version: str | None, + checklist_checks: dict[str, dict[str, Any]], + diagnostics: list[Diagnostic], +) -> None: + for kind in ("required_surfaces", "taxonomy", "results"): + value = loaded.get(kind) + if not isinstance(value, dict) or value.get("baseline_version") != version: + add(diagnostics, "BASELINE_VERSION_MISMATCH", f"/{kind}/baseline_version") + inventory = loaded.get("inventory") + flows = inventory.get("flows") if isinstance(inventory, dict) else None + if not isinstance(flows, list): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/inventory/flows") + return + statuses: dict[str, str] = {} + for index, flow in enumerate(flows): + if not isinstance(flow, dict) or not FLOW_RE.fullmatch(str(flow.get("id", ""))): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/inventory/flows/{index}") + continue + flow_id = flow["id"] + status = flow.get("status") + if flow_id in statuses or status not in IMPLEMENTATION_STATUSES: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/inventory/flows/{index}") + continue + statuses[flow_id] = status + required = loaded.get("required_surfaces") + required_ids = required.get("required_flow_ids") if exact_keys(required, {"baseline_version", "required_flow_ids", "surface_groups"}) else None + current_ids = {flow_id for flow_id, status in statuses.items() if status != "planned"} + if ( + not isinstance(required_ids, list) + or any(not isinstance(flow_id, str) for flow_id in required_ids) + or len(required_ids) != len(set(required_ids)) + or set(required_ids) != current_ids + ): + add(diagnostics, "INVALID_REQUIRED_SURFACES", "/required_surfaces/required_flow_ids") + required_ids = [] + groups = required.get("surface_groups") if isinstance(required, dict) else None + grouped: list[str] = [] + group_ids: set[str] = set() + if not isinstance(groups, list) or not 1 <= len(groups) <= 64: + add(diagnostics, "INVALID_REQUIRED_SURFACES", "/required_surfaces/surface_groups") + groups = [] + for index, group in enumerate(groups): + pointer = f"/required_surfaces/surface_groups/{index}" + if not exact_keys(group, {"id", "flow_ids"}) or not FLOW_RE.fullmatch(str(group.get("id", ""))): + add(diagnostics, "INVALID_REQUIRED_SURFACES", pointer) + continue + group_flow_ids = group.get("flow_ids") + if group["id"] in group_ids or not isinstance(group_flow_ids, list) or not group_flow_ids or any(not isinstance(item, str) for item in group_flow_ids): + add(diagnostics, "INVALID_REQUIRED_SURFACES", pointer) + continue + group_ids.add(group["id"]) + grouped.extend(group_flow_ids) + if len(grouped) != len(set(grouped)) or set(grouped) != set(required_ids): + add(diagnostics, "INVALID_REQUIRED_SURFACES", "/required_surfaces/surface_groups") + taxonomy = loaded.get("taxonomy") + if isinstance(taxonomy, dict): + taxonomy_required = {"baseline_version", "implementation_statuses", "execution_verdicts", "evidence_modes", "full_pass"} + taxonomy_optional = {"manual_only_rule", "non_pass_rule"} + if not exact_keys(taxonomy, taxonomy_required, taxonomy_optional): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/taxonomy") + for rule in taxonomy_optional & set(taxonomy): + if not nonempty_text(taxonomy.get(rule), 2048): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/taxonomy/{rule}") + if set(taxonomy.get("implementation_statuses", [])) != IMPLEMENTATION_STATUSES: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/taxonomy/implementation_statuses") + if set(taxonomy.get("execution_verdicts", [])) != EXECUTION_VERDICTS: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/taxonomy/execution_verdicts") + if set(taxonomy.get("evidence_modes", [])) != EVIDENCE_MODES: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/taxonomy/evidence_modes") + if taxonomy.get("full_pass") != {"implementation_status": "implemented", "execution_verdict": "pass", "evidence_mode": "automated"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/taxonomy/full_pass") + results = loaded.get("results") + if not isinstance(results, dict): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results") + return + if set(results) != {"baseline_version", "source_revision", "environment_class", "runs", "manual_results", "defects"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results") + root_revision = results.get("source_revision") + root_environment = results.get("environment_class") + if not isinstance(root_revision, str) or not re.fullmatch(r"[0-9a-f]{40}", root_revision): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results/source_revision") + if not isinstance(root_environment, str) or not re.fullmatch(r"[a-z0-9-]{1,64}", root_environment): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results/environment_class") + safe_serialized = json.dumps(results, sort_keys=True) + if UNSAFE_RE.search(safe_serialized): + add(diagnostics, "UNSAFE_EVIDENCE", "/results") + evidenced: set[str] = set() + seen_run_ids: set[str] = set() + seen_hashes: set[str] = set() + runs = results.get("runs") + if not isinstance(runs, list): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results/runs") + runs = [] + for index, run in enumerate(runs): + pointer = f"/results/runs/{index}" + if not isinstance(run, dict): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", pointer) + continue + required_run_keys = {"id", "command_id", "flow_ids", "execution_verdict", "evidence_mode", "accepted", "source_report_sha256", "source_revision", "environment_class", "collector"} + if not exact_keys(run, required_run_keys, {"summary", "safe_outcome"}): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", pointer) + verdict = run.get("execution_verdict") + mode = run.get("evidence_mode") + accepted = run.get("accepted") + source_revision = run.get("source_revision") + environment_class = run.get("environment_class") + command_id = run.get("command_id") + source_hash = run.get("source_report_sha256") + run_id = run.get("id") + if not nonempty_text(run_id, 128) or run_id in seen_run_ids: + add(diagnostics, "DUPLICATE_RUN_ID", f"{pointer}/id") + elif isinstance(run_id, str): + seen_run_ids.add(run_id) + if command_id not in COMMAND_IDS: + add(diagnostics, "UNKNOWN_COMMAND", f"{pointer}/command_id") + if not isinstance(source_revision, str) or not re.fullmatch(r"[0-9a-f]{40}", source_revision): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"{pointer}/source_revision") + elif source_revision != root_revision: + add(diagnostics, "PROVENANCE_MISMATCH", f"{pointer}/source_revision") + if not isinstance(environment_class, str) or not re.fullmatch(r"[a-z0-9-]{1,64}", environment_class): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"{pointer}/environment_class") + elif environment_class != root_environment: + add(diagnostics, "PROVENANCE_MISMATCH", f"{pointer}/environment_class") + if not isinstance(source_hash, str) or not HEX_RE.fullmatch(source_hash): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"{pointer}/source_report_sha256") + elif source_hash in seen_hashes: + add(diagnostics, "DUPLICATE_REPORT_HASH", f"{pointer}/source_report_sha256") + else: + seen_hashes.add(source_hash) + if verdict not in EXECUTION_VERDICTS or mode not in EVIDENCE_MODES: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", pointer) + expected_accepted = verdict == "pass" and mode == "automated" and run.get("collector") == "capability-baseline-collector-v1" + if type(accepted) is not bool or accepted is not expected_accepted: + add(diagnostics, "NON_PASS_RECORDED_AS_PASS", pointer) + flow_ids = run.get("flow_ids") + if not isinstance(flow_ids, list) or not flow_ids or any(not isinstance(flow_id, str) for flow_id in flow_ids) or len(flow_ids) != len(set(flow_ids)): + add(diagnostics, "MISSING_FLOW_EVIDENCE", f"{pointer}/flow_ids") + continue + for flow_id in flow_ids: + if flow_id not in statuses: + add(diagnostics, "UNKNOWN_FLOW_ID", f"{pointer}/flow_ids") + elif accepted is True and statuses[flow_id] != "implemented": + add(diagnostics, "NON_PASS_RECORDED_AS_PASS", f"{pointer}/flow_ids") + elif accepted is True: + evidenced.add(flow_id) + for flow_id, status in statuses.items(): + if status == "implemented" and flow_id not in evidenced: + add(diagnostics, "MISSING_FLOW_EVIDENCE", f"/inventory/{flow_id}") + manual_results = results.get("manual_results") + if not isinstance(manual_results, list) or len(manual_results) > 1000: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results/manual_results") + manual_results = [] + seen_checks: set[str] = set() + for index, manual in enumerate(manual_results): + pointer = f"/results/manual_results/{index}" + required_manual_keys = {"check_id", "evidence_mode", "execution_verdict", "flow_ids", "next_evidence"} + if not exact_keys(manual, required_manual_keys): + add(diagnostics, "INVALID_MANUAL_EVIDENCE", pointer) + continue + check_id = manual.get("check_id") + flow_ids = manual.get("flow_ids") + if check_id in seen_checks or check_id not in checklist_checks: + add(diagnostics, "INVALID_MANUAL_EVIDENCE", f"{pointer}/check_id") + elif isinstance(check_id, str): + seen_checks.add(check_id) + if manual.get("evidence_mode") != "manual_only" or manual.get("execution_verdict") not in EXECUTION_VERDICTS: + add(diagnostics, "INVALID_MANUAL_EVIDENCE", pointer) + if not nonempty_text(manual.get("next_evidence"), 2048): + add(diagnostics, "INVALID_MANUAL_EVIDENCE", f"{pointer}/next_evidence") + if not isinstance(flow_ids, list) or not flow_ids or any(not isinstance(flow_id, str) or flow_id not in statuses for flow_id in flow_ids): + add(diagnostics, "INVALID_MANUAL_EVIDENCE", f"{pointer}/flow_ids") + elif isinstance(check_id, str) and check_id in checklist_checks: + if checklist_checks[check_id]["flow_id"] not in flow_ids or checklist_checks[check_id]["verdict"] != manual.get("execution_verdict"): + add(diagnostics, "INVALID_MANUAL_EVIDENCE", pointer) + if seen_checks != set(checklist_checks): + add(diagnostics, "INVALID_MANUAL_EVIDENCE", "/results/manual_results") + defects = results.get("defects") + if not isinstance(defects, list): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", "/results/defects") + defects = [] + for index, defect in enumerate(defects): + if not isinstance(defect, dict): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/results/defects/{index}") + continue + required_fields = ("id", "severity", "contract", "owner", "steps", "next_action", "flow_ids") + if set(defect) != set(required_fields): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/results/defects/{index}") + continue + if defect.get("severity") not in {"Critical", "High", "Medium", "Low"}: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/results/defects/{index}/severity") + if not isinstance(defect.get("steps"), list) or not 1 <= len(defect["steps"]) <= 20: + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/results/defects/{index}/steps") + defect_flow_ids = defect.get("flow_ids") + if not isinstance(defect_flow_ids, list) or not defect_flow_ids or any(not isinstance(flow_id, str) for flow_id in defect_flow_ids): + add(diagnostics, "INVALID_SCHEMA_CONTRACT", f"/results/defects/{index}/flow_ids") + defect_flow_ids = [] + for flow_id in defect_flow_ids: + if flow_id not in statuses: + add(diagnostics, "UNKNOWN_FLOW_ID", f"/results/defects/{index}/flow_ids") + elif defect.get("severity") in SEVERE and statuses[flow_id] != "blocked": + add(diagnostics, "SEVERE_DEFECT_FLOW_NOT_BLOCKED", f"/results/defects/{index}") + + +def render(diagnostics: list[Diagnostic]) -> str: + lines = [f"{item.code} pointer={item.pointer}" for item in sorted(set(diagnostics))] + encoded = "\n".join(lines) + ("\n" if lines else "") + raw = encoded.encode("utf-8") + if len(raw) <= MAX_REPORT_BYTES: + return encoded + marker = "REPORT_TRUNCATED pointer=/\n" + budget = MAX_REPORT_BYTES - len(marker.encode("utf-8")) + return raw[:budget].decode("utf-8", "ignore") + marker + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--manifest", required=True) + parser.add_argument("--schema", required=True) + args = parser.parse_args() + root = Path(args.root) + try: + diagnostics, version, flow_count = validate(root, args.manifest, args.schema) + except (OSError, ValueError, RecursionError): + diagnostics, version, flow_count = [Diagnostic("VALIDATION_FAILED", "/")], None, 0 + if diagnostics: + sys.stderr.write(render(diagnostics)) + return 1 + print(f"Capability baseline validation passed: baseline_version={version} flows={flow_count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-capability-inventory.py b/scripts/validate-capability-inventory.py new file mode 100644 index 0000000..bf0c19d --- /dev/null +++ b/scripts/validate-capability-inventory.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""Validate the versioned Crank Community Capability Inventory contract.""" + +from __future__ import annotations + +import argparse +import heapq +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Iterable, Iterator + + +SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema" +MAX_DOCUMENT_BYTES = 4_194_304 +MAX_FLOWS = 10_000 +MAX_REQUIREMENTS_PER_FLOW = 64 +MAX_CAPABILITIES_PER_FLOW = 16 +MAX_EVIDENCE_LINKS_PER_KIND = 64 +MAX_ID_LENGTH = 128 +MAX_REQUIREMENT_LENGTH = 128 +MAX_CAPABILITY_LENGTH = 128 +MAX_OWNER_LENGTH = 256 +MAX_OUTCOME_LENGTH = 4_096 +MAX_NOTES_LENGTH = 4_096 +MAX_EVIDENCE_PATH_LENGTH = 1_024 +MAX_DIAGNOSTICS = 1_000 +MAX_REPORT_BYTES = 65_536 + +FLOW_TYPES = ("ui", "api", "mcp") +FLOW_STATUSES = ("implemented", "planned", "gap", "blocked") +ALLOWED_CAPABILITIES = ("tools", "resources", "prompts", "tasks", "load_runs") +FORBIDDEN_CAPABILITIES = ( + "multi_workspace", # community-scope: allow=multi-workspace + "enterprise_rbac", # community-scope: allow=enterprise + "sso", # community-scope: allow=sso + "non_rest_upstream", # community-scope: allow=non-rest-upstream + "arbitrary_distributed_load_targets", # community-scope: allow=distributed-load-targets +) + +TOP_LEVEL_FIELDS = frozenset({"schema_version", "product", "flows"}) +FLOW_FIELDS = frozenset( + { + "id", + "type", + "requirements", + "user_outcome", + "owner", + "status", + "capabilities", + "evidence", + "notes", + } +) +EVIDENCE_FIELDS = frozenset({"automated", "manual"}) + +SCHEMA_ALLOWED_KEYS: dict[tuple[str, ...], frozenset[str]] = { + (): frozenset( + { + "$schema", + "$id", + "title", + "type", + "additionalProperties", + "required", + "properties", + "$defs", + } + ), + ("properties",): frozenset({"schema_version", "product", "flows"}), + ("properties", "schema_version"): frozenset({"const"}), + ("properties", "product"): frozenset({"const"}), + ("properties", "flows"): frozenset({"type", "minItems", "maxItems", "items"}), + ("properties", "flows", "items"): frozenset({"$ref"}), + ("$defs",): frozenset({"flow", "evidence", "evidencePaths"}), + ("$defs", "flow"): frozenset( + {"type", "additionalProperties", "required", "properties"} + ), + ("$defs", "flow", "properties"): FLOW_FIELDS, + ("$defs", "flow", "properties", "id"): frozenset( + {"type", "minLength", "maxLength", "pattern"} + ), + ("$defs", "flow", "properties", "type"): frozenset({"enum"}), + ("$defs", "flow", "properties", "requirements"): frozenset( + {"type", "minItems", "maxItems", "uniqueItems", "items"} + ), + ("$defs", "flow", "properties", "requirements", "items"): frozenset( + {"type", "minLength", "maxLength", "pattern"} + ), + ("$defs", "flow", "properties", "user_outcome"): frozenset( + {"type", "minLength", "maxLength", "pattern"} + ), + ("$defs", "flow", "properties", "owner"): frozenset( + {"type", "minLength", "maxLength", "pattern"} + ), + ("$defs", "flow", "properties", "status"): frozenset({"enum"}), + ("$defs", "flow", "properties", "capabilities"): frozenset( + {"type", "minItems", "maxItems", "uniqueItems", "items"} + ), + ("$defs", "flow", "properties", "capabilities", "items"): frozenset({"enum"}), + ("$defs", "flow", "properties", "evidence"): frozenset({"$ref"}), + ("$defs", "flow", "properties", "notes"): frozenset({"type", "maxLength"}), + ("$defs", "evidence"): frozenset( + {"type", "additionalProperties", "required", "properties"} + ), + ("$defs", "evidence", "properties"): EVIDENCE_FIELDS, + ("$defs", "evidence", "properties", "automated"): frozenset({"$ref"}), + ("$defs", "evidence", "properties", "manual"): frozenset({"$ref"}), + ("$defs", "evidencePaths"): frozenset( + {"type", "minItems", "maxItems", "uniqueItems", "items"} + ), + ("$defs", "evidencePaths", "items"): frozenset( + {"type", "minLength", "maxLength", "pattern"} + ), +} + +FLOW_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +REQUIREMENT_ID = re.compile(r"^FR-[1-9][0-9]*$") +NON_WHITESPACE_PATTERN = r"[\s\S]*\S[\s\S]*" +EVIDENCE_PATH_PATTERN = r"^(?!/)(?!.*//)(?!.*(?:^|/)\.\.?($|/))(?!.*\\).+$" + + +@dataclass(frozen=True, order=True) +class Diagnostic: + code: str + pointer: str + message: str + + +@dataclass(frozen=True) +class _ReverseDiagnostic: + diagnostic: Diagnostic + + def __lt__(self, other: "_ReverseDiagnostic") -> bool: + return self.diagnostic > other.diagnostic + + +class DiagnosticCollector: + """Retain only the lexicographically first diagnostics with an exact omitted count.""" + + def __init__(self) -> None: + self._heap: list[_ReverseDiagnostic] = [] + self.total = 0 + + def append(self, diagnostic: Diagnostic) -> None: + self.total += 1 + wrapped = _ReverseDiagnostic(diagnostic) + if len(self._heap) < MAX_DIAGNOSTICS: + heapq.heappush(self._heap, wrapped) + elif diagnostic < self._heap[0].diagnostic: + heapq.heapreplace(self._heap, wrapped) + + def extend(self, diagnostics: Iterable[Diagnostic] | "DiagnosticCollector") -> None: + if isinstance(diagnostics, DiagnosticCollector): + for diagnostic in diagnostics.ordered(): + self.append(diagnostic) + self.total += diagnostics.omitted + return + for diagnostic in diagnostics: + self.append(diagnostic) + + @property + def omitted(self) -> int: + return self.total - len(self._heap) + + def ordered(self) -> list[Diagnostic]: + return sorted(item.diagnostic for item in self._heap) + + def __bool__(self) -> bool: + return self.total > 0 + + +class DuplicateJsonMemberError(ValueError): + pass + + +def reject_duplicate_json_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise DuplicateJsonMemberError("duplicate JSON object member") + value[key] = item + return value + + +def reject_nonstandard_json_constant(_: str) -> None: + raise ValueError("non-standard JSON numeric constant") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--inventory", required=True) + parser.add_argument("--schema", required=True) + parser.add_argument("--required-fr", action="append", default=[]) + return parser.parse_args() + + +def path_has_symlink(root: Path, logical: PurePosixPath) -> bool: + current = root + for part in logical.parts: + current = current / part + if current.is_symlink(): + return True + return False + + +def resolve_regular_file(root: Path, raw: str) -> Path | None: + if not raw or len(raw) > MAX_EVIDENCE_PATH_LENGTH or "\\" in raw: + return None + logical = PurePosixPath(raw) + if logical.is_absolute() or any(part in {"", ".", ".."} for part in logical.parts): + return None + if str(logical) != raw or path_has_symlink(root, logical): + return None + candidate = root.joinpath(*logical.parts) + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (FileNotFoundError, OSError, RuntimeError, ValueError): + return None + if not resolved.is_file(): + return None + return resolved + + +def read_json_document( + root: Path, + raw_path: str, + invalid_code: str, +) -> tuple[Any | None, Diagnostic | None]: + path = resolve_regular_file(root, raw_path) + pointer = "/schema" if invalid_code == "INVALID_SCHEMA" else "/inventory" + if path is None: + return None, Diagnostic(invalid_code, pointer, "document path is unavailable") + try: + if path.stat().st_size > MAX_DOCUMENT_BYTES: + return None, Diagnostic("INPUT_TOO_LARGE", pointer, "document exceeds limit") + data = path.read_bytes() + if len(data) > MAX_DOCUMENT_BYTES: + return None, Diagnostic("INPUT_TOO_LARGE", pointer, "document exceeds limit") + text = data.decode("utf-8") + return ( + json.loads( + text, + object_pairs_hook=reject_duplicate_json_members, + parse_constant=reject_nonstandard_json_constant, + ), + None, + ) + except (OSError, UnicodeDecodeError, ValueError, RecursionError, json.JSONDecodeError): + return None, Diagnostic(invalid_code, pointer, "document is not valid UTF-8 JSON") + + +def nested(document: Any, *parts: str) -> Any: + current = document + for part in parts: + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + +def contract_values_equal(actual: Any, expected: Any) -> bool: + if isinstance(expected, list): + return ( + isinstance(actual, list) + and all(isinstance(item, str) for item in actual) + and len(actual) == len(set(actual)) + and set(actual) == set(expected) + ) + return type(actual) is type(expected) and actual == expected + + +def validate_required_keyword( + schema: dict[str, Any], parts: tuple[str, ...], expected: frozenset[str] +) -> list[Diagnostic]: + raw = nested(schema, *parts) + valid = ( + isinstance(raw, list) + and all(isinstance(item, str) for item in raw) + and len(raw) == len(set(raw)) + and set(raw) == expected + ) + if valid: + return [] + return [ + Diagnostic( + "INVALID_SCHEMA_CONTRACT", + "/schema/" + "/".join(parts), + "required fields differ from validator", + ) + ] + + +def validate_schema_contract(schema: Any) -> list[Diagnostic]: + expected: list[tuple[tuple[str, ...], Any]] = [ + (("$schema",), SCHEMA_DIALECT), + (("$id",), "https://crank.local/schemas/capability-inventory.schema.json"), + (("title",), "Crank Community Capability Inventory"), + (("type",), "object"), + (("additionalProperties",), False), + (("properties", "schema_version", "const"), 1), + (("properties", "product", "const"), "crank-community"), + (("properties", "flows", "type"), "array"), + (("properties", "flows", "maxItems"), MAX_FLOWS), + (("properties", "flows", "minItems"), 1), + (("properties", "flows", "items", "$ref"), "#/$defs/flow"), + (("$defs", "flow", "type"), "object"), + (("$defs", "flow", "additionalProperties"), False), + (("$defs", "flow", "properties", "id", "type"), "string"), + (("$defs", "flow", "properties", "id", "minLength"), 1), + (("$defs", "flow", "properties", "id", "maxLength"), MAX_ID_LENGTH), + (("$defs", "flow", "properties", "id", "pattern"), FLOW_ID.pattern), + (("$defs", "flow", "properties", "type", "enum"), list(FLOW_TYPES)), + (("$defs", "flow", "properties", "status", "enum"), list(FLOW_STATUSES)), + ( + ("$defs", "flow", "properties", "requirements", "maxItems"), + MAX_REQUIREMENTS_PER_FLOW, + ), + (("$defs", "flow", "properties", "requirements", "type"), "array"), + (("$defs", "flow", "properties", "requirements", "minItems"), 1), + (("$defs", "flow", "properties", "requirements", "uniqueItems"), True), + ( + ("$defs", "flow", "properties", "requirements", "items", "type"), + "string", + ), + (("$defs", "flow", "properties", "requirements", "items", "minLength"), 1), + ( + ("$defs", "flow", "properties", "requirements", "items", "maxLength"), + MAX_REQUIREMENT_LENGTH, + ), + ( + ("$defs", "flow", "properties", "requirements", "items", "pattern"), + REQUIREMENT_ID.pattern, + ), + ( + ("$defs", "flow", "properties", "capabilities", "maxItems"), + MAX_CAPABILITIES_PER_FLOW, + ), + (("$defs", "flow", "properties", "capabilities", "type"), "array"), + (("$defs", "flow", "properties", "capabilities", "minItems"), 1), + (("$defs", "flow", "properties", "capabilities", "uniqueItems"), True), + ( + ("$defs", "flow", "properties", "capabilities", "items", "enum"), + list(ALLOWED_CAPABILITIES), + ), + (("$defs", "flow", "properties", "owner", "type"), "string"), + (("$defs", "flow", "properties", "owner", "minLength"), 1), + (("$defs", "flow", "properties", "owner", "maxLength"), MAX_OWNER_LENGTH), + (("$defs", "flow", "properties", "owner", "pattern"), NON_WHITESPACE_PATTERN), + (("$defs", "flow", "properties", "user_outcome", "type"), "string"), + (("$defs", "flow", "properties", "user_outcome", "minLength"), 1), + ( + ("$defs", "flow", "properties", "user_outcome", "maxLength"), + MAX_OUTCOME_LENGTH, + ), + ( + ("$defs", "flow", "properties", "user_outcome", "pattern"), + NON_WHITESPACE_PATTERN, + ), + (("$defs", "flow", "properties", "evidence", "$ref"), "#/$defs/evidence"), + (("$defs", "flow", "properties", "notes", "type"), "string"), + (("$defs", "flow", "properties", "notes", "maxLength"), MAX_NOTES_LENGTH), + (("$defs", "evidence", "type"), "object"), + (("$defs", "evidence", "additionalProperties"), False), + ( + ("$defs", "evidence", "properties", "automated", "$ref"), + "#/$defs/evidencePaths", + ), + ( + ("$defs", "evidence", "properties", "manual", "$ref"), + "#/$defs/evidencePaths", + ), + (("$defs", "evidencePaths", "type"), "array"), + (("$defs", "evidencePaths", "minItems"), 1), + (("$defs", "evidencePaths", "maxItems"), MAX_EVIDENCE_LINKS_PER_KIND), + (("$defs", "evidencePaths", "uniqueItems"), True), + (("$defs", "evidencePaths", "items", "type"), "string"), + (("$defs", "evidencePaths", "items", "minLength"), 1), + (("$defs", "evidencePaths", "items", "maxLength"), MAX_EVIDENCE_PATH_LENGTH), + (("$defs", "evidencePaths", "items", "pattern"), EVIDENCE_PATH_PATTERN), + ] + if not isinstance(schema, dict): + return [Diagnostic("INVALID_SCHEMA_CONTRACT", "/schema", "schema must be an object")] + diagnostics = [] + for parts, allowed_keys in SCHEMA_ALLOWED_KEYS.items(): + node = schema if not parts else nested(schema, *parts) + if not isinstance(node, dict): + continue + if any(key not in allowed_keys for key in node): + pointer = "/schema" + ("/" + "/".join(parts) if parts else "") + diagnostics.append( + Diagnostic( + "INVALID_SCHEMA_CONTRACT", + f"{pointer}/", + "schema contains an unsupported keyword", + ) + ) + for parts, value in expected: + if not contract_values_equal(nested(schema, *parts), value): + diagnostics.append( + Diagnostic( + "INVALID_SCHEMA_CONTRACT", + "/schema/" + "/".join(parts), + "schema contract differs from validator", + ) + ) + diagnostics.extend( + validate_required_keyword( + schema, + ("required",), + frozenset({"schema_version", "product", "flows"}), + ) + ) + diagnostics.extend( + validate_required_keyword( + schema, + ("$defs", "flow", "required"), + frozenset(FLOW_FIELDS - {"notes"}), + ) + ) + diagnostics.extend( + validate_required_keyword( + schema, + ("$defs", "evidence", "required"), + EVIDENCE_FIELDS, + ) + ) + return diagnostics + + +def is_nonempty_string(value: Any, maximum: int) -> bool: + return isinstance(value, str) and bool(value.strip()) and len(value) <= maximum + + +def validate_string_list( + value: Any, + pointer: str, + maximum_items: int, + maximum_length: int, + pattern: re.Pattern[str] | None = None, +) -> list[Diagnostic]: + if not isinstance(value, list) or not value: + return [Diagnostic("MISSING_REQUIRED_FIELD", pointer, "non-empty list is required")] + diagnostics: list[Diagnostic] = [] + if len(value) > maximum_items: + diagnostics.append(Diagnostic("LIMIT_EXCEEDED", pointer, "collection exceeds limit")) + seen: set[str] = set() + for index, item in enumerate(value[: maximum_items + 1]): + if not is_nonempty_string(item, maximum_length): + diagnostics.append( + Diagnostic( + "LIMIT_EXCEEDED", + f"{pointer}/{index}", + "string is empty, invalid, or exceeds limit", + ) + ) + continue + if pattern is not None and pattern.fullmatch(item) is None: + diagnostics.append( + Diagnostic("INVALID_FORMAT", f"{pointer}/{index}", "string format is invalid") + ) + if item in seen: + diagnostics.append( + Diagnostic( + "DUPLICATE_LIST_ITEM", + f"{pointer}/{index}", + "collection item is duplicated", + ) + ) + seen.add(item) + return diagnostics + + +def reject_unknown_fields( + value: dict[str, Any], allowed: frozenset[str], pointer: str +) -> Iterator[Diagnostic]: + for field in value: + if field not in allowed: + yield Diagnostic("UNKNOWN_FIELD", f"{pointer}/", "field is not allowed") + + +def validate_evidence(root: Path, value: Any, pointer: str) -> Iterator[Diagnostic]: + if not isinstance(value, dict): + yield Diagnostic("MISSING_REQUIRED_FIELD", pointer, "evidence object is required") + return + yield from reject_unknown_fields(value, EVIDENCE_FIELDS, pointer) + for kind in ("automated", "manual"): + paths = value.get(kind) + path_pointer = f"{pointer}/{kind}" + yield from validate_string_list( + paths, + path_pointer, + MAX_EVIDENCE_LINKS_PER_KIND, + MAX_EVIDENCE_PATH_LENGTH, + ) + if not isinstance(paths, list): + continue + for index, raw_path in enumerate(paths[: MAX_EVIDENCE_LINKS_PER_KIND + 1]): + if not isinstance(raw_path, str) or resolve_regular_file(root, raw_path) is None: + yield Diagnostic( + "BROKEN_EVIDENCE_LINK", + f"{path_pointer}/{index}", + "evidence path is unavailable", + ) + + +def validate_inventory( + root: Path, + inventory: Any, + required_frs: list[str], +) -> tuple[DiagnosticCollector, dict[str, int]]: + counts = {status: 0 for status in FLOW_STATUSES} + diagnostics = DiagnosticCollector() + if not isinstance(inventory, dict): + diagnostics.append(Diagnostic("MISSING_REQUIRED_FIELD", "/", "inventory must be an object")) + return diagnostics, counts + + diagnostics.extend(reject_unknown_fields(inventory, TOP_LEVEL_FIELDS, "")) + schema_version = inventory.get("schema_version") + if type(schema_version) is not int or schema_version != 1: + diagnostics.append( + Diagnostic("MISSING_REQUIRED_FIELD", "/schema_version", "schema_version 1 is required") + ) + if inventory.get("product") != "crank-community": + diagnostics.append( + Diagnostic("MISSING_REQUIRED_FIELD", "/product", "product identity is required") + ) + flows = inventory.get("flows") + if not isinstance(flows, list) or not flows: + diagnostics.append(Diagnostic("MISSING_REQUIRED_FIELD", "/flows", "non-empty flows are required")) + return diagnostics, counts + if len(flows) > MAX_FLOWS: + diagnostics.append(Diagnostic("LIMIT_EXCEEDED", "/flows", "flow collection exceeds limit")) + + seen_ids: set[str] = set() + observed_frs: set[str] = set() + required_fields = ( + "id", + "type", + "requirements", + "user_outcome", + "owner", + "status", + "capabilities", + "evidence", + ) + for index, flow in enumerate(flows[: MAX_FLOWS + 1]): + pointer = f"/flows/{index}" + if not isinstance(flow, dict): + diagnostics.append(Diagnostic("MISSING_REQUIRED_FIELD", pointer, "flow must be an object")) + continue + diagnostics.extend(reject_unknown_fields(flow, FLOW_FIELDS, pointer)) + for field in required_fields: + if field not in flow: + code = "MISSING_OWNER" if field == "owner" else "MISSING_REQUIRED_FIELD" + diagnostics.append(Diagnostic(code, f"{pointer}/{field}", "field is required")) + + flow_id = flow.get("id") + if not is_nonempty_string(flow_id, MAX_ID_LENGTH) or not FLOW_ID.fullmatch(flow_id): + diagnostics.append(Diagnostic("LIMIT_EXCEEDED", f"{pointer}/id", "flow id is invalid")) + elif flow_id in seen_ids: + diagnostics.append(Diagnostic("DUPLICATE_FLOW_ID", f"{pointer}/id", "flow id is duplicated")) + else: + seen_ids.add(flow_id) + + flow_type = flow.get("type") + if flow_type not in FLOW_TYPES: + diagnostics.append(Diagnostic("UNKNOWN_FLOW_TYPE", f"{pointer}/type", "flow type is unknown")) + + owner = flow.get("owner") + if "owner" in flow: + if not isinstance(owner, str) or not owner.strip(): + diagnostics.append( + Diagnostic("MISSING_OWNER", f"{pointer}/owner", "owner is required") + ) + elif len(owner) > MAX_OWNER_LENGTH: + diagnostics.append( + Diagnostic("LIMIT_EXCEEDED", f"{pointer}/owner", "owner exceeds limit") + ) + + outcome = flow.get("user_outcome") + if "user_outcome" in flow and not is_nonempty_string(outcome, MAX_OUTCOME_LENGTH): + diagnostics.append( + Diagnostic("LIMIT_EXCEEDED", f"{pointer}/user_outcome", "user outcome is invalid") + ) + + notes = flow.get("notes") + if notes is not None and (not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH): + diagnostics.append(Diagnostic("LIMIT_EXCEEDED", f"{pointer}/notes", "notes exceed limit")) + + status = flow.get("status") + if status not in FLOW_STATUSES: + diagnostics.append(Diagnostic("UNKNOWN_STATUS", f"{pointer}/status", "status is unknown")) + else: + counts[status] += 1 + + requirements = flow.get("requirements") + diagnostics.extend( + validate_string_list( + requirements, + f"{pointer}/requirements", + MAX_REQUIREMENTS_PER_FLOW, + MAX_REQUIREMENT_LENGTH, + REQUIREMENT_ID, + ) + ) + if isinstance(requirements, list): + for requirement in requirements[: MAX_REQUIREMENTS_PER_FLOW + 1]: + if isinstance(requirement, str) and REQUIREMENT_ID.fullmatch(requirement): + observed_frs.add(requirement) + + capabilities = flow.get("capabilities") + diagnostics.extend( + validate_string_list( + capabilities, + f"{pointer}/capabilities", + MAX_CAPABILITIES_PER_FLOW, + MAX_CAPABILITY_LENGTH, + ) + ) + if isinstance(capabilities, list): + for cap_index, capability in enumerate(capabilities[: MAX_CAPABILITIES_PER_FLOW + 1]): + cap_pointer = f"{pointer}/capabilities/{cap_index}" + if capability in FORBIDDEN_CAPABILITIES: + diagnostics.append( + Diagnostic( + "FORBIDDEN_COMMUNITY_CAPABILITY", + cap_pointer, + f"forbidden capability: {capability}", + ) + ) + elif isinstance(capability, str) and capability not in ALLOWED_CAPABILITIES: + diagnostics.append( + Diagnostic("LIMIT_EXCEEDED", cap_pointer, "capability is not allowed") + ) + + diagnostics.extend(validate_evidence(root, flow.get("evidence"), f"{pointer}/evidence")) + + for required_fr in sorted(set(required_frs)): + if not REQUIREMENT_ID.fullmatch(required_fr) or len(required_fr) > MAX_REQUIREMENT_LENGTH: + diagnostics.append( + Diagnostic("LIMIT_EXCEEDED", "/required-fr", "required FR identifier is invalid") + ) + elif required_fr not in observed_frs: + diagnostics.append( + Diagnostic( + "MISSING_REQUIRED_FR", + "/requirements", + f"mandatory requirement is missing: {required_fr}", + ) + ) + return diagnostics, counts + + +def render_report(diagnostics: DiagnosticCollector) -> str: + selected = diagnostics.ordered() + lines = [f"error[{item.code}] {item.pointer}: {item.message}" for item in selected] + if diagnostics.omitted: + lines.append(f"error[TRUNCATED] /: diagnostics omitted={diagnostics.omitted}") + + report = "\n".join(lines) + "\n" + encoded = report.encode("utf-8") + if len(encoded) <= MAX_REPORT_BYTES: + return report + + marker = "error[TRUNCATED] /: report byte limit reached\n" + marker_bytes = marker.encode("utf-8") + budget = MAX_REPORT_BYTES - len(marker_bytes) + kept: list[str] = [] + used = 0 + for line in lines: + line_bytes = (line + "\n").encode("utf-8") + if used + len(line_bytes) > budget: + break + kept.append(line) + used += len(line_bytes) + return "\n".join(kept) + ("\n" if kept else "") + marker + + +def main() -> int: + args = parse_args() + root = args.root.resolve() + + schema, schema_error = read_json_document(root, args.schema, "INVALID_SCHEMA") + inventory, inventory_error = read_json_document(root, args.inventory, "INVALID_JSON") + diagnostics = DiagnosticCollector() + diagnostics.extend(error for error in (schema_error, inventory_error) if error is not None) + if schema_error is None: + diagnostics.extend(validate_schema_contract(schema)) + + counts = {status: 0 for status in FLOW_STATUSES} + if inventory_error is None: + inventory_diagnostics, counts = validate_inventory(root, inventory, args.required_fr) + diagnostics.extend(inventory_diagnostics) + + if diagnostics: + sys.stderr.write(render_report(diagnostics)) + return 1 + + total = sum(counts.values()) + print( + "Capability inventory validation passed " + f"total={total} implemented={counts['implemented']} " + f"planned={counts['planned']} gap={counts['gap']} " + f"blocked={counts['blocked']} pass={counts['implemented']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_authenticated_product_smoke.py b/tests/unit/test_authenticated_product_smoke.py index f279c6b..5f36299 100644 --- a/tests/unit/test_authenticated_product_smoke.py +++ b/tests/unit/test_authenticated_product_smoke.py @@ -1,4 +1,5 @@ import importlib.util +import json import unittest from pathlib import Path @@ -38,6 +39,10 @@ class AuthenticatedProductSmokeTests(unittest.TestCase): smoke.agent_mcp_url("https://crank.example.com/", "solo", "health-smoke"), "https://crank.example.com/mcp/v1/solo/health-smoke", ) + self.assertEqual( + smoke.agent_mcp_url("http://127.0.0.1:3302", "solo", "health-smoke", ""), + "http://127.0.0.1:3302/v1/solo/health-smoke", + ) def test_resolve_workspace_prefers_authenticated_session_slug(self) -> None: smoke = load_smoke_module() @@ -79,6 +84,116 @@ class AuthenticatedProductSmokeTests(unittest.TestCase): self.assertEqual(payload["params"]["name"], "internal_health_smoke") self.assertEqual(payload["params"]["arguments"], {"probe": "ok"}) + def test_tool_call_requires_non_error_expected_structured_outcome(self) -> None: + smoke = load_smoke_module() + smoke.validate_tool_call_result({"result": {"isError": False, "structuredContent": {"status": "ok"}}}) + for value in ( + {"result": {"isError": True, "structuredContent": {"status": "ok"}}}, + {"result": {"isError": False, "structuredContent": {"status": "failed"}}}, + {"error": {"message": "secret-canary"}}, + [], + ): + with self.subTest(value=value), self.assertRaises(smoke.SmokeError) as raised: + smoke.validate_tool_call_result(value) + self.assertNotIn("secret-canary", str(raised.exception)) + + def test_tools_list_and_key_shape_fail_through_safe_error(self) -> None: + smoke = load_smoke_module() + for value in (None, {"result": {"tools": ["bad"]}}, {"result": {"tools": [{"name": 7}]}}): + with self.subTest(value=value), self.assertRaises(smoke.SmokeError): + smoke.validate_tools_list(value, "expected") + + class BadKeyClient: + def request_json(self, *args, **kwargs): + return smoke.JsonResponse(200, {}, {"api_key": {}}) + + with self.assertRaises(smoke.SmokeError): + smoke.create_agent_key(BadKeyClient(), "ws", "agent") + + def test_session_termination_uses_delete_without_exposing_key(self) -> None: + smoke = load_smoke_module() + + class FakeClient: + def request_json(self, method, url, **kwargs): + self.call = (method, url, kwargs) + return smoke.JsonResponse(204, {}, None) + + client = FakeClient() + smoke.terminate_mcp_session(client, "https://private.invalid/mcp", "secret-canary", "session-safe") + self.assertEqual(client.call[0], "DELETE") + self.assertEqual(client.call[2]["expected"], (204, 404)) + + def test_operation_and_agent_versions_come_from_api_responses(self) -> None: + smoke = load_smoke_module() + + class FakeClient: + def __init__(self): + self.requests = [] + + def request_json(self, method, path, payload=None, **kwargs): + self.requests.append((method, path, payload)) + if path.endswith("/operations"): + return smoke.JsonResponse(200, {}, {"operation_id": "op_safe", "version": 7}) + if path.endswith("/publish") and "/operations/" in path: + return smoke.JsonResponse(200, {}, {"published_version": 7}) + if path.endswith("/agents"): + return smoke.JsonResponse(200, {}, {"agent_id": "agent_safe", "version": 3}) + if path.endswith("/bindings"): + return smoke.JsonResponse(200, {}, {}) + if path.endswith("/publish") and "/agents/" in path: + return smoke.JsonResponse(200, {}, {"published_version": 3}) + raise AssertionError(path) + + client = FakeClient() + operation_id, operation_version = smoke.create_operation(client, "ws", "safe", "http://admin-api:3001") + published_operation_version = smoke.publish_operation(client, "ws", operation_id, operation_version) + agent_id, agent_version = smoke.create_agent(client, "ws", "safe-agent") + published_agent_version = smoke.bind_and_publish_agent( + client, "ws", agent_id, agent_version, operation_id, published_operation_version, "safe" + ) + + self.assertEqual((operation_id, operation_version, published_operation_version), ("op_safe", 7, 7)) + self.assertEqual((agent_id, agent_version, published_agent_version), ("agent_safe", 3, 3)) + binding = next(payload for _, path, payload in client.requests if path.endswith("/bindings"))[0] + self.assertEqual(binding["operation_version"], 7) + + def test_admin_test_run_uses_created_version_and_rejects_failed_outcome(self) -> None: + smoke = load_smoke_module() + + class FakeClient: + def __init__(self, ok): + self.ok = ok + self.payload = None + + def request_json(self, method, path, payload=None, **kwargs): + self.payload = payload + return smoke.JsonResponse(200, {}, {"ok": self.ok, "errors": [{"message": "secret-canary"}]}) + + passing = FakeClient(True) + smoke.run_operation_test(passing, "ws", "op", 9) + self.assertEqual(passing.payload, {"version": 9, "input": {"probe": "ok"}}) + + with self.assertRaises(smoke.SmokeError) as raised: + smoke.run_operation_test(FakeClient(False), "ws", "op", 9) + self.assertNotIn("secret-canary", str(raised.exception)) + + def test_http_failure_message_is_bounded_and_redacted(self) -> None: + smoke = load_smoke_module() + error = smoke.safe_error("http", "unexpected_status", status=500) + rendered = str(error) + self.assertEqual(rendered, "stage=http code=unexpected_status status=500") + self.assertNotIn("http://", rendered) + self.assertLessEqual(len(rendered.encode("utf-8")), 256) + + def test_safe_summary_contains_revisions_but_not_secrets_or_payloads(self) -> None: + smoke = load_smoke_module() + summary = smoke.build_safe_summary("op_safe", 7, "agent_safe", 3) + encoded = json.dumps(summary, sort_keys=True) + self.assertIn('"operation_version": 7', encoded) + self.assertIn('"agent_revision": 3', encoded) + for forbidden in ("secret", "cookie", "authorization", "payload", "session"): + self.assertNotIn(forbidden, encoded.lower()) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_check_community_scope.py b/tests/unit/test_check_community_scope.py index 73b03ee..0e7f9ca 100644 --- a/tests/unit/test_check_community_scope.py +++ b/tests/unit/test_check_community_scope.py @@ -1,14 +1,26 @@ +import importlib.util import subprocess import sys import tempfile import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[2] CHECKER = ROOT / "scripts" / "check-community-scope.py" +def load_checker_module(): + spec = importlib.util.spec_from_file_location("community_scope_checker", CHECKER) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load Community scope checker") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + class CommunityScopeCheckTests(unittest.TestCase): def run_checker(self, root: Path, files: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run( @@ -19,6 +31,15 @@ class CommunityScopeCheckTests(unittest.TestCase): stderr=subprocess.PIPE, ) + def run_default_checker(self, root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CHECKER), "--root", str(root)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + def test_passes_for_clean_community_content(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -91,6 +112,36 @@ class CommunityScopeCheckTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) + def test_excluded_explicit_paths_are_validated_before_skip(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "target").mkdir() + (root / "target" / "generated.txt").write_text( + "generated output\n", encoding="utf-8" + ) + + valid_excluded = self.run_checker(root, ["target/generated.txt"]) + self.assertEqual(valid_excluded.returncode, 0, valid_excluded.stderr) + + for raw_path in ["LICENSE", "target/missing.txt", "target/../missing.txt"]: + with self.subTest(raw_path=raw_path): + rejected = self.run_checker(root, [raw_path]) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("invalid explicit file path", rejected.stderr) + + def test_default_discovery_ignores_tracked_deletions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + deleted = root / "deleted.txt" + deleted.write_text("clean\n", encoding="utf-8") + subprocess.run(["git", "add", "deleted.txt"], cwd=root, check=True) + deleted.unlink() + + result = self.run_default_checker(root) + + self.assertEqual(result.returncode, 0, result.stderr) + def test_skips_binary_files(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -100,6 +151,167 @@ class CommunityScopeCheckTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) + def test_allows_planned_community_capability_identifiers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "inventory.txt").write_text( + "resources prompts tasks load_runs\n", + encoding="utf-8", + ) + + result = self.run_checker(root, ["inventory.txt"]) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_rejects_forbidden_identifier_after_narrow_inline_allow(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_text( + "enterprise_rbac enforcement fixture " + "# community-scope: allow=enterprise\n" + "enterprise_rbac product capability\n", + encoding="utf-8", + ) + + result = self.run_checker(root, ["scope.txt"]) + + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("scope.txt:1:", result.stderr) + self.assertIn("scope.txt:2: forbidden marker `enterprise`", result.stderr) + + def test_explicit_paths_fail_closed_without_disclosing_host_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + outside = root.parent / "SECRET_CANARY_scope-outside.txt" + outside.write_text("clean\n", encoding="utf-8") + self.addCleanup(outside.unlink, missing_ok=True) + internal_target = root / "target.txt" + internal_target.write_text("clean\n", encoding="utf-8") + internal_link = root / "internal-link.txt" + internal_link.symlink_to(internal_target) + outside_link = root / "outside-link.txt" + outside_link.symlink_to(outside) + + cases = [ + "missing.txt", + f"../{outside.name}", + str(outside), + "internal-link.txt", + "outside-link.txt", + ] + for raw_path in cases: + with self.subTest(raw_path=raw_path): + result = self.run_checker(root, [raw_path]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid explicit file path", result.stderr) + self.assertNotIn("SECRET_CANARY", result.stderr) + + def test_invalid_path_report_is_bounded_and_deterministically_truncated(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + missing = [f"missing-{index:04d}.txt" for index in range(1_200, 0, -1)] + + first = self.run_checker(root, missing) + second = self.run_checker(root, missing) + + self.assertNotEqual(first.returncode, 0) + self.assertEqual(first.stderr, second.stderr) + self.assertLessEqual(len(first.stderr.encode("utf-8")), 64 * 1024) + self.assertIn("error: findings truncated", first.stderr) + self.assertNotIn("missing-", first.stderr) + + def test_single_file_finding_limit_emits_truncation_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_text( + "grpc\n" * 1_001, + encoding="utf-8", + ) + + result = self.run_checker(root, ["scope.txt"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("error: findings truncated", result.stderr) + self.assertLessEqual(len(result.stderr.encode("utf-8")), 64 * 1024) + + def test_exact_finding_limit_is_not_reported_as_truncated(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_text("grpc\n" * 1_000, encoding="utf-8") + + result = self.run_checker(root, ["scope.txt"]) + + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("error: findings truncated", result.stderr) + + def test_rejects_camel_and_pascal_case_forbidden_identifiers(self) -> None: + cases = { + "EnterpriseRbac": "enterprise", + "MultiWorkspace": "multi-workspace", + "NonRestUpstream": "non-rest-upstream", + "ArbitraryDistributedLoadTargets": "distributed-load-targets", + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for index, (identifier, marker) in enumerate(cases.items()): + path = root / f"scope-{index}.txt" + path.write_text(identifier + "\n", encoding="utf-8") + with self.subTest(identifier=identifier): + result = self.run_checker(root, [path.name]) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"forbidden marker `{marker}`", result.stderr) + + def test_invalid_utf8_text_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_bytes(b"enterpr\xffise capability\n") + + result = self.run_checker(root, ["scope.txt"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid explicit file path", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_control_characters_in_explicit_paths_cannot_forge_report_lines(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + forged_path = "forged\nerror: injected.txt" + (root / forged_path).write_text("grpc\n", encoding="utf-8") + + result = self.run_checker(root, [forged_path]) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stderr.count("\n"), 2) + self.assertNotIn("forged", result.stderr) + self.assertNotIn("injected", result.stderr) + + def test_oversized_single_line_fails_closed_without_materializing_it(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_text("x" * 65_537, encoding="utf-8") + + result = self.run_checker(root, ["scope.txt"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid explicit file path", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_open_failure_after_validation_returns_a_safe_failure(self) -> None: + checker = load_checker_module() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "scope.txt").write_text("clean\n", encoding="utf-8") + + with mock.patch.object(Path, "open", side_effect=OSError("host path canary")): + findings, valid, truncated, scanned_bytes = checker.scan_file( + root, "scope.txt" + ) + + self.assertEqual(findings, []) + self.assertFalse(valid) + self.assertFalse(truncated) + self.assertEqual(scanned_bytes, 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_check_config_boundaries.py b/tests/unit/test_check_config_boundaries.py new file mode 100644 index 0000000..8dd6389 --- /dev/null +++ b/tests/unit/test_check_config_boundaries.py @@ -0,0 +1,137 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts/check-config-boundaries.py" + + +class ConfigBoundaryTests(unittest.TestCase): + def run_check(self, root: Path, *files: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SCRIPT), "--root", str(root), "--files", *files], + text=True, + capture_output=True, + check=False, + ) + + def test_value_only_production_code_passes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "crates/example/src/lib.rs" + path.parent.mkdir(parents=True) + path.write_text("pub fn configured(value: u32) -> u32 { value }\n", encoding="utf-8") + result = self.run_check(root, "crates/example/src/lib.rs") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_direct_alias_and_hidden_reader_forms_fail(self) -> None: + cases = ( + "std::env::var(\"X\")", + "std /* split */ ::\n env :: var(\"X\")", + "use std::env as process_environment;", + "use std::{collections::BTreeMap, env};", + "pub use std :: env :: var as exported_reader;", + "use std as standard; standard :: env :: var(\"X\");", + "extern crate std as standard; standard::env::vars();", + "use std::{self as system}; system::env::var(\"X\");", + "env::vars_os()", + "fn community_from_env() {}", + "dotenvy::dotenv()", + "option_env!(\"SECRET\")", + "env!(\"SECRET\")", + ) + for index, source in enumerate(cases): + with self.subTest(source=source), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + relative = f"crates/example/src/case_{index}.rs" + path = root / relative + path.parent.mkdir(parents=True) + path.write_text(source, encoding="utf-8") + result = self.run_check(root, relative) + self.assertNotEqual(result.returncode, 0) + + def test_only_package_version_compile_time_macro_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + relative = "apps/example/src/main.rs" + path = root / relative + path.parent.mkdir(parents=True) + path.write_text( + 'const VERSION: &str = env! ( "CARGO_PKG_VERSION" );\n', + encoding="utf-8", + ) + result = self.run_check(root, relative) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_command_line_arguments_are_not_environment_configuration(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + relative = "apps/example/src/main.rs" + path = root / relative + path.parent.mkdir(parents=True) + path.write_text( + "fn main() { let _ = std::env::args().next(); }\n", + encoding="utf-8", + ) + result = self.run_check(root, relative) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_comments_and_strings_do_not_trigger_false_positives(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + relative = "crates/example/src/lib.rs" + path = root / relative + path.parent.mkdir(parents=True) + path.write_text( + '// std::env::var("X")\nconst NOTE: &str = "option_env!(\\\"X\\\")";\n', + encoding="utf-8", + ) + result = self.run_check(root, relative) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_missing_traversal_and_symlink_paths_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + outside = root.parent / "config-boundary-outside.rs" + outside.write_text("std::env::var(\"X\");", encoding="utf-8") + self.addCleanup(outside.unlink, missing_ok=True) + for supplied in ("missing.rs", "../config-boundary-outside.rs"): + result = self.run_check(root, supplied) + self.assertNotEqual(result.returncode, 0) + link = root / "link.rs" + link.symlink_to(outside) + result = self.run_check(root, "link.rs") + self.assertNotEqual(result.returncode, 0) + + def test_default_scan_covers_build_scripts_and_rejects_symlinks(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + build = root / "crates/example/build.rs" + build.parent.mkdir(parents=True) + build.write_text('std::env::var("X");', encoding="utf-8") + result = subprocess.run( + ["python3", str(SCRIPT), "--root", str(root)], + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + + build.unlink() + outside = root / "outside.rs" + outside.write_text("fn safe() {}", encoding="utf-8") + link = root / "apps/example/src/link.rs" + link.parent.mkdir(parents=True) + link.symlink_to(outside) + result = subprocess.run( + ["python3", str(SCRIPT), "--root", str(root)], + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_check_migration_boundaries.py b/tests/unit/test_check_migration_boundaries.py new file mode 100644 index 0000000..bc39b6e --- /dev/null +++ b/tests/unit/test_check_migration_boundaries.py @@ -0,0 +1,48 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts" / "check-migration-boundaries.py" + + +class MigrationBoundaryTests(unittest.TestCase): + def run_check(self, root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SCRIPT), "--root", str(root)], + text=True, + capture_output=True, + check=False, + ) + + def test_rejects_ddl_runner_and_sql_include_outside_authority(self) -> None: + samples = ( + 'sqlx::query("CREATE UNIQUE INDEX idx ON sample(id)");', + 'sqlx::query("CREATE TEMP TABLE sample(id int)");', + 'sqlx::query("CREATE TYPE state AS ENUM (\'ready\')");', + 'sqlx::migrate!("./migrations");', + 'include_str!("local.sql");', + 'sqlx::query("INSERT INTO __crank_migrations VALUES (1)");', + ) + for index, sample in enumerate(samples): + with self.subTest(index=index), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "apps" / "sample" / "src" / "main.rs" + source.parent.mkdir(parents=True) + source.write_text(sample, encoding="utf-8") + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + + def test_allows_canonical_migration_assets(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "crates" / "crank-registry" / "src" / "migrations" / "v1.sql" + source.parent.mkdir(parents=True) + source.write_text("CREATE TABLE sample(id int);", encoding="utf-8") + result = self.run_check(root) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_check_runtime_config.py b/tests/unit/test_check_runtime_config.py new file mode 100644 index 0000000..880f29f --- /dev/null +++ b/tests/unit/test_check_runtime_config.py @@ -0,0 +1,173 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "scripts/check-runtime-config.py" + + +def field(name: str, process: str = "shared", mode: str = "effective") -> dict: + return { + "env_name": name, + "process": process, + "mode": mode, + "required": False, + "default": "value", + "sensitivity": "public", + } + + +class RuntimeConfigContractTests(unittest.TestCase): + def fixture(self) -> Path: + root = Path(tempfile.mkdtemp()) + (root / "docs/schemas").mkdir(parents=True) + (root / "deploy/community").mkdir(parents=True) + contract = { + "schema_version": 1, + "fields": [ + field("CRANK_SHARED"), + field("CRANK_ADMIN", "admin_api"), + field("CRANK_MCP", "mcp_server"), + field("CRANK_OLD", mode="deprecated_no_effect"), + ], + "deployment_only_fields": ["COMPOSE_PROJECT_NAME"], + } + (root / "docs/schemas/runtime-config.schema.json").write_text( + json.dumps(contract), encoding="utf-8" + ) + section = ( + "# BEGIN GENERATED CRANK RUNTIME CONFIG\n" + "CRANK_ADMIN=value\nCRANK_MCP=value\nCRANK_SHARED=value\n" + "# END GENERATED CRANK RUNTIME CONFIG\n" + ) + for relative in ( + ".env.example", + "deploy/community/.env.example", + "deploy/community/.env.images.example", + ): + (root / relative).write_text(section, encoding="utf-8") + compose = ( + "services:\n" + " admin-api:\n environment:\n" + " CRANK_SHARED: ${CRANK_SHARED:-value}\n" + " CRANK_ADMIN: ${CRANK_ADMIN:-value}\n" + " mcp-server:\n environment:\n" + " CRANK_SHARED: ${CRANK_SHARED:-value}\n" + " CRANK_MCP: ${CRANK_MCP:-value}\n" + ) + for relative in ( + "docker-compose.yml", + "deploy/community/docker-compose.yml", + "deploy/community/docker-compose.images.yml", + ): + (root / relative).write_text(compose, encoding="utf-8") + return root + + def run_check(self, root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SCRIPT), "--root", str(root)], + text=True, + capture_output=True, + check=False, + ) + + def test_valid_contract_passes(self) -> None: + result = self.run_check(self.fixture()) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_missing_generated_field_fails_closed(self) -> None: + root = self.fixture() + path = root / ".env.example" + path.write_text( + path.read_text().replace("CRANK_MCP=value\n", ""), encoding="utf-8" + ) + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + + def test_compose_extra_or_wrong_process_field_fails(self) -> None: + root = self.fixture() + path = root / "docker-compose.yml" + path.write_text( + path.read_text().replace( + "CRANK_ADMIN: ${CRANK_ADMIN:-value}", + "CRANK_ADMIN: ${CRANK_ADMIN:-value}\n" + " CRANK_MCP: ${CRANK_MCP:-value}", + ), + encoding="utf-8", + ) + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + + def test_divergent_inline_default_fails(self) -> None: + root = self.fixture() + path = root / "docker-compose.yml" + path.write_text( + path.read_text().replace("${CRANK_SHARED:-value}", "${CRANK_SHARED:-drift}", 1), + encoding="utf-8", + ) + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("divergent Compose inline default", result.stderr) + + def test_wrong_interpolation_reference_fails(self) -> None: + root = self.fixture() + path = root / "docker-compose.yml" + path.write_text( + path.read_text().replace( + "CRANK_ADMIN: ${CRANK_ADMIN:-value}", + "CRANK_ADMIN: ${CRANK_SHARED:-value}", + ), + encoding="utf-8", + ) + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("wrong Compose interpolation reference", result.stderr) + + def test_required_and_empty_operator_forms_are_enforced(self) -> None: + for required, expression in ( + (True, "${CRANK_SHARED:-value}"), + (False, "${CRANK_SHARED-value}"), + (False, "${CRANK_SHARED:?required}"), + ): + with self.subTest(required=required, expression=expression): + root = self.fixture() + schema_path = root / "docs/schemas/runtime-config.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + schema["fields"][0]["required"] = required + schema_path.write_text(json.dumps(schema), encoding="utf-8") + compose_path = root / "docker-compose.yml" + compose_path.write_text( + compose_path.read_text().replace( + "${CRANK_SHARED:-value}", expression, 1 + ), + encoding="utf-8", + ) + result = self.run_check(root) + self.assertNotEqual(result.returncode, 0) + + def test_required_empty_aware_error_form_passes(self) -> None: + root = self.fixture() + schema_path = root / "docs/schemas/runtime-config.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + schema["fields"][0]["required"] = True + schema_path.write_text(json.dumps(schema), encoding="utf-8") + for relative in ( + "docker-compose.yml", + "deploy/community/docker-compose.yml", + "deploy/community/docker-compose.images.yml", + ): + path = root / relative + path.write_text( + path.read_text().replace( + "${CRANK_SHARED:-value}", "${CRANK_SHARED:?must be configured}" + ), + encoding="utf-8", + ) + result = self.run_check(root) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_check_rust_boundaries.py b/tests/unit/test_check_rust_boundaries.py index 503cfee..5fc3b4f 100644 --- a/tests/unit/test_check_rust_boundaries.py +++ b/tests/unit/test_check_rust_boundaries.py @@ -166,6 +166,23 @@ class RustBoundaryCheckTests(unittest.TestCase): self.assertEqual(violations[0].source, "crank-metrics") self.assertEqual(violations[0].dependency, "crank-core") + def test_rejects_config_contract_dependency_on_workspace_crates(self) -> None: + packages = [ + package( + self.root, + "crank-config", + "crates/crank-config", + ["crank-core"], + ), + package(self.root, "crank-core", "crates/crank-core"), + ] + + violations = self.checker.find_violations(metadata(packages, self.root)) + + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0].source, "crank-config") + self.assertEqual(violations[0].dependency, "crank-core") + def test_rejects_domain_and_runtime_dependencies_on_observability(self) -> None: for source in ("crank-core", "crank-registry", "crank-runtime"): packages = [ diff --git a/tests/unit/test_collect_capability_baseline.py b/tests/unit/test_collect_capability_baseline.py new file mode 100644 index 0000000..b601af3 --- /dev/null +++ b/tests/unit/test_collect_capability_baseline.py @@ -0,0 +1,121 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +COLLECTOR = ROOT / "scripts" / "collect-capability-baseline.py" + + +class CapabilityBaselineCollectorTests(unittest.TestCase): + def run_playwright(self, report: object) -> tuple[subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + report_path = root / "report.json" + output_path = root / "candidate.json" + report_path.write_text(json.dumps(report), encoding="utf-8") + result = subprocess.run( + ["python3", str(COLLECTOR), "playwright", "--report", str(report_path), "--output", str(output_path), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "ui-operations"], + text=True, + capture_output=True, + check=False, + ) + return result, output_path, temporary + + def test_stable_playwright_pass_is_collected(self) -> None: + report = {"suites": [{"specs": [{"tests": [{"status": "expected", "results": [{"status": "passed", "retry": 0}]}]}]}]} + result, output, temporary = self.run_playwright(report) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + candidate = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(candidate["execution_verdict"], "pass") + self.assertTrue(candidate["accepted"]) + self.assertEqual(candidate["evidence_mode"], "automated") + self.assertRegex(candidate["source_report_sha256"], "^[0-9a-f]{64}$") + + def test_retry_pass_is_flaky_and_not_accepted(self) -> None: + report = {"suites": [{"specs": [{"tests": [{"status": "flaky", "results": [{"status": "failed", "retry": 0}, {"status": "passed", "retry": 1}]}]}]}]} + result, output, temporary = self.run_playwright(report) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + candidate = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(candidate["execution_verdict"], "flaky") + self.assertFalse(candidate["accepted"]) + + def test_skipped_and_missing_results_are_not_pass(self) -> None: + report = {"suites": [{"specs": [{"tests": [{"status": "skipped", "results": []}]}]}]} + result, output, temporary = self.run_playwright(report) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["execution_verdict"], "skipped") + + def test_raw_report_content_never_reaches_candidate_or_error(self) -> None: + canary = "Bearer secret-canary /home/private/workspace https://private.invalid?q=secret" + report = {"suites": [], "errors": [{"message": canary}], "stdout": [canary]} + result, output, temporary = self.run_playwright(report) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["execution_verdict"], "fail") + self.assertNotIn(canary, output.read_text(encoding="utf-8")) + self.assertNotIn(canary, result.stderr) + + def test_report_level_error_and_malformed_result_never_pass(self) -> None: + reports = [ + {"errors": [{"message": "fatal"}], "suites": [{"specs": [{"tests": [{"status": "expected", "results": [{"status": "passed"}]}]}]}]}, + {"suites": [{"specs": [{"tests": [{"status": "expected", "results": ["not-an-object"]}]}]}]}, + ] + for report in reports: + with self.subTest(report=report): + result, output, temporary = self.run_playwright(report) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(json.loads(output.read_text(encoding="utf-8"))["accepted"]) + + def test_oversized_report_fails_without_traceback(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + report = root / "report.json" + report.write_bytes(b" " * (4 * 1024 * 1024 + 1)) + result = subprocess.run( + ["python3", str(COLLECTOR), "playwright", "--report", str(report), "--output", str(root / "out.json"), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "ui-operations"], + text=True, capture_output=True, check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("INPUT_TOO_LARGE", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_allowlisted_command_report_derives_exit_verdict(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + report = root / "command.json" + output = root / "candidate.json" + report.write_text(json.dumps({"command_id": "rust-admin-integration", "exit_code": 7, "timed_out": False, "skipped": 0}), encoding="utf-8") + result = subprocess.run( + ["python3", str(COLLECTOR), "command-report", "--report", str(report), "--output", str(output), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "api-operation-test-run"], + text=True, capture_output=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + candidate = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(candidate["execution_verdict"], "fail") + self.assertFalse(candidate["accepted"]) + + def test_unknown_command_report_is_rejected(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + report = root / "command.json" + report.write_text(json.dumps({"command_id": "arbitrary-shell", "exit_code": 0, "timed_out": False, "skipped": 0}), encoding="utf-8") + result = subprocess.run( + ["python3", str(COLLECTOR), "command-report", "--report", str(report), "--output", str(root / "candidate.json"), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "api-operation-test-run"], + text=True, capture_output=True, check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("UNKNOWN_COMMAND", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_validate_capability_baseline.py b/tests/unit/test_validate_capability_baseline.py new file mode 100644 index 0000000..f6ed9d6 --- /dev/null +++ b/tests/unit/test_validate_capability_baseline.py @@ -0,0 +1,283 @@ +import hashlib +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "scripts" / "validate-capability-baseline.py" +VERSION = "2026.08.08.1" + + +def dump(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class CapabilityBaselineValidatorTests(unittest.TestCase): + def make_root(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + inventory = { + "schema_version": 1, + "product": "crank-community", + "flows": [ + { + "id": "api-operation-test-run", + "type": "api", + "requirements": ["FR-2", "FR-46"], + "user_outcome": "An administrator can execute an Operation test run.", + "owner": "runtime-community", + "status": "implemented", + "capabilities": ["tools"], + "evidence": {"automated": ["docs/capability-baseline/results.json"], "manual": ["docs/capability-baseline/manual-checklist.md"]}, + } + ], + } + required = { + "baseline_version": VERSION, + "required_flow_ids": ["api-operation-test-run"], + "surface_groups": [{"id": "operations", "flow_ids": ["api-operation-test-run"]}], + } + taxonomy = { + "baseline_version": VERSION, + "implementation_statuses": ["implemented", "planned", "gap", "blocked"], + "execution_verdicts": ["pass", "fail", "blocked", "skipped", "flaky", "not_run"], + "evidence_modes": ["automated", "manual_only"], + "full_pass": {"implementation_status": "implemented", "execution_verdict": "pass", "evidence_mode": "automated"}, + } + checklist = f"# Community UI baseline checklist\n\nbaseline_version: {VERSION}\n\n## UI-01 Operations\n\n- flow_id: api-operation-test-run\n- states: happy, error, recovery\n- verdict: not_run\n- reason: API-only fixture\n" + results = { + "baseline_version": VERSION, + "source_revision": "0123456789abcdef0123456789abcdef01234567", + "environment_class": "community-test", + "runs": [{ + "id": "run-api-operation-test", + "command_id": "python-tooling-tests", + "flow_ids": ["api-operation-test-run"], + "execution_verdict": "pass", + "evidence_mode": "automated", + "accepted": True, + "source_report_sha256": "a" * 64, + "collector": "capability-baseline-collector-v1", + "source_revision": "0123456789abcdef0123456789abcdef01234567", + "environment_class": "community-test", + }], + "manual_results": [{ + "check_id": "UI-01", + "evidence_mode": "manual_only", + "execution_verdict": "not_run", + "flow_ids": ["api-operation-test-run"], + "next_evidence": "Automate this fixture state.", + }], + "defects": [], + } + paths = { + "inventory": root / "docs/capability-inventory.json", + "required_surfaces": root / "docs/capability-baseline/required-surfaces.json", + "taxonomy": root / "docs/capability-baseline/outcome-taxonomy.json", + "checklist": root / "docs/capability-baseline/manual-checklist.md", + "results": root / "docs/capability-baseline/results.json", + } + dump(paths["inventory"], inventory) + dump(paths["required_surfaces"], required) + dump(paths["taxonomy"], taxonomy) + paths["checklist"].write_text(checklist, encoding="utf-8") + dump(paths["results"], results) + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crank.local/schemas/capability-baseline.schema.json", + "title": "Crank Community Capability Baseline Manifest", + "type": "object", + "additionalProperties": False, + "required": ["schema_version", "baseline_version", "artifacts"], + "properties": { + "schema_version": {"const": 1}, + "baseline_version": {"type": "string", "pattern": "^[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}\\.[1-9][0-9]*$"}, + "artifacts": {"type": "array", "minItems": 5, "maxItems": 5}, + }, + "$defs": { + "artifact": {"type": "object", "additionalProperties": False}, + "taxonomy": { + "type": "object", "additionalProperties": False, + "properties": {"full_pass": {"properties": {"implementation_status": {}, "execution_verdict": {}, "evidence_mode": {}}}}, + }, + "run": { + "type": "object", "additionalProperties": False, + "required": ["id", "command_id", "flow_ids", "execution_verdict", "evidence_mode", "accepted", "source_report_sha256", "source_revision", "environment_class", "collector"], + }, + "defect": { + "type": "object", "additionalProperties": False, + "required": ["id", "severity", "steps", "contract", "owner", "flow_ids", "next_action"], + }, + "manual_result": {"type": "object", "additionalProperties": False}, + "results": {"type": "object", "additionalProperties": False}, + }, + } + dump(root / "docs/schemas/capability-baseline.schema.json", schema) + manifest = { + "schema_version": 1, + "baseline_version": VERSION, + "artifacts": [ + {"kind": kind, "path": str(path.relative_to(root)), "sha256": sha256(path)} + for kind, path in paths.items() + ], + } + dump(root / "docs/capability-baseline/manifest.json", manifest) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run( + ["git", "-C", str(root), "add", *[str(path.relative_to(root)) for path in paths.values()]], + check=True, + ) + return temporary, root + + def run_validator(self, root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(VALIDATOR), "--root", str(root), "--manifest", "docs/capability-baseline/manifest.json", "--schema", "docs/schemas/capability-baseline.schema.json"], + text=True, + capture_output=True, + check=False, + ) + + def mutate_results(self, root: Path, mutate) -> None: + path = root / "docs/capability-baseline/results.json" + value = json.loads(path.read_text(encoding="utf-8")) + mutate(value) + dump(path, value) + manifest_path = root / "docs/capability-baseline/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + next(item for item in manifest["artifacts"] if item["kind"] == "results")["sha256"] = sha256(path) + dump(manifest_path, manifest) + + def mutate_artifact(self, root: Path, kind: str, mutate) -> None: + manifest_path = root / "docs/capability-baseline/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + item = next(entry for entry in manifest["artifacts"] if entry["kind"] == kind) + path = root / item["path"] + value = json.loads(path.read_text(encoding="utf-8")) + mutate(value) + dump(path, value) + item["sha256"] = sha256(path) + dump(manifest_path, manifest) + + def test_valid_snapshot_passes(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + result = self.run_validator(root) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("baseline_version=2026.08.08.1", result.stdout) + + def test_checksum_drift_fails_closed(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + with (root / "docs/capability-baseline/manual-checklist.md").open("a", encoding="utf-8") as file: + file.write("\nchanged\n") + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("CHECKSUM_MISMATCH", result.stderr) + + def test_unknown_flow_and_non_pass_cannot_be_accepted(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_results(root, lambda value: value["runs"][0].update(flow_ids=["missing-flow"], execution_verdict="flaky")) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("UNKNOWN_FLOW_ID", result.stderr) + self.assertIn("NON_PASS_RECORDED_AS_PASS", result.stderr) + + def test_severe_defect_requires_blocked_inventory_flow(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_results(root, lambda value: value["defects"].append({ + "id": "DEF-001", "severity": "High", "flow_ids": ["api-operation-test-run"], + "contract": "Admin test run", "owner": "runtime-community", "steps": ["Run bounded fixture"], "next_action": "Fix downstream", + })) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("SEVERE_DEFECT_FLOW_NOT_BLOCKED", result.stderr) + + def test_version_mismatch_and_broken_artifact_path_are_safe(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + manifest_path = root / "docs/capability-baseline/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["baseline_version"] = "2026.08.08.2" + manifest["artifacts"][0]["path"] = "../outside-secret.txt" + dump(manifest_path, manifest) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("BROKEN_ARTIFACT_LINK", result.stderr) + self.assertNotIn(str(root), result.stderr) + + def test_unsupported_schema_applicators_fail_closed_at_any_depth(self) -> None: + for mutation in ( + lambda schema: schema.update({"not": {}}), + lambda schema: schema["properties"]["artifacts"].update({"allOf": []}), + ): + with self.subTest(mutation=mutation): + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + schema_path = root / "docs/schemas/capability-baseline.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + mutation(schema) + dump(schema_path, schema) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("INVALID_SCHEMA_CONTRACT", result.stderr) + + def test_required_surfaces_cannot_omit_or_duplicate_implemented_flow(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_artifact(root, "required_surfaces", lambda value: value.update(required_flow_ids=[], surface_groups=[])) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("INVALID_REQUIRED_SURFACES", result.stderr) + + def test_manual_results_and_taxonomy_truth_table_are_enforced(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_results(root, lambda value: value["manual_results"][0].pop("next_evidence")) + self.mutate_artifact(root, "taxonomy", lambda value: value["full_pass"].update(implementation_status="planned")) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("INVALID_MANUAL_EVIDENCE", result.stderr) + self.assertIn("/taxonomy/full_pass", result.stderr) + + def test_accepted_run_requires_known_command_matching_provenance_and_implemented_flow(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_results(root, lambda value: value["runs"][0].update(command_id="fabricated", source_revision="f" * 40, flow_ids=[{}])) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("UNKNOWN_COMMAND", result.stderr) + self.assertIn("PROVENANCE_MISMATCH", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_planned_flow_cannot_receive_accepted_evidence(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + self.mutate_artifact(root, "inventory", lambda value: value["flows"].append({ + **value["flows"][0], "id": "planned-resource-read", "status": "planned", + })) + self.mutate_results(root, lambda value: value["runs"][0].update(flow_ids=["planned-resource-read"])) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("NON_PASS_RECORDED_AS_PASS", result.stderr) + + def test_untracked_canonical_artifact_fails_closed(self) -> None: + temporary, root = self.make_root() + self.addCleanup(temporary.cleanup) + subprocess.run(["git", "-C", str(root), "rm", "--cached", "docs/capability-baseline/results.json"], check=True, capture_output=True) + result = self.run_validator(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("UNTRACKED_ARTIFACT", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_validate_capability_inventory.py b/tests/unit/test_validate_capability_inventory.py new file mode 100644 index 0000000..85e83ed --- /dev/null +++ b/tests/unit/test_validate_capability_inventory.py @@ -0,0 +1,842 @@ +import copy +import importlib.util +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "scripts" / "validate-capability-inventory.py" +CANONICAL_INVENTORY = ROOT / "docs" / "capability-inventory.json" +CANONICAL_SCHEMA = ROOT / "docs" / "schemas" / "capability-inventory.schema.json" +COMMUNITY_CHECKER = ROOT / "scripts" / "check-community-scope.py" +COMMUNITY_README = ROOT / "README.md" +DOCS_INDEX = ROOT / "docs" / "README.md" +COMMUNITY_INTRO = ROOT / "docs" / "intro.md" +COMMUNITY_README_EN = ROOT / "docs" / "en" / "README.md" +JUSTFILE = ROOT / "justfile" +CI_WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yml" +RELEASE_WORKFLOW = ROOT / ".gitea" / "workflows" / "release.yml" +REQUIRED_SURFACES = ROOT / "docs" / "capability-baseline" / "required-surfaces.json" + + +def load_validator_module(): + spec = importlib.util.spec_from_file_location("capability_inventory_validator", VALIDATOR) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load capability inventory validator") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + +class CapabilityInventoryValidatorTests(unittest.TestCase): + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self._temporary_directory.name) + self.inventory_path = self.root / "docs" / "capability-inventory.json" + self.schema_path = ( + self.root / "docs" / "schemas" / "capability-inventory.schema.json" + ) + self.inventory_path.parent.mkdir(parents=True) + self.schema_path.parent.mkdir(parents=True) + + def tearDown(self) -> None: + self._temporary_directory.cleanup() + + def write_evidence(self, relative_path: str, content: str = "verified\n") -> str: + path = self.root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return relative_path + + def valid_flow( + self, + flow_id: str, + flow_type: str, + requirement: str, + status: str = "implemented", + capabilities: list[str] | None = None, + ) -> dict[str, object]: + automated = self.write_evidence( + f"evidence/automated/{flow_id}.txt", + f"{flow_id}: automated acceptance evidence\n", + ) + manual = self.write_evidence( + f"evidence/manual/{flow_id}.md", + f"# {flow_id}\n\nManual verification complete.\n", + ) + return { + "id": flow_id, + "type": flow_type, + "requirements": [requirement], + "user_outcome": f"User completes {flow_id.replace('-', ' ')}.", + "owner": "community-release", + "status": status, + "capabilities": capabilities or ["tools"], + "evidence": { + "automated": [automated], + "manual": [manual], + }, + } + + def write_inventory(self, flows: list[dict[str, object]]) -> None: + payload = { + "schema_version": 1, + "product": "crank-community", + "flows": flows, + } + self.inventory_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + def install_canonical_schema(self) -> None: + self.assertTrue( + CANONICAL_SCHEMA.is_file(), + f"Story 1.1 must create {CANONICAL_SCHEMA.relative_to(ROOT)}", + ) + shutil.copyfile(CANONICAL_SCHEMA, self.schema_path) + + def run_validator( + self, + required_frs: list[str], + install_schema: bool = True, + ) -> subprocess.CompletedProcess[str]: + if install_schema: + self.install_canonical_schema() + command = [ + sys.executable, + str(VALIDATOR), + "--root", + str(self.root), + "--inventory", + str(self.inventory_path.relative_to(self.root)), + "--schema", + str(self.schema_path.relative_to(self.root)), + ] + for requirement in required_frs: + command.extend(["--required-fr", requirement]) + return subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def test_accepts_complete_inventory_with_all_flow_types_and_statuses(self) -> None: + flows = [ + self.valid_flow("ui-create-method", "ui", "FR-1", "implemented"), + self.valid_flow("api-create-method", "api", "FR-2", "planned"), + self.valid_flow("mcp-tool-call", "mcp", "FR-3", "gap"), + self.valid_flow("mcp-resource-read", "mcp", "FR-4", "blocked"), + ] + self.write_inventory(flows) + + result = self.run_validator(["FR-1", "FR-2", "FR-3", "FR-4"]) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Capability inventory validation passed", result.stdout) + self.assertRegex(result.stdout, r"\btotal=4\b") + self.assertRegex(result.stdout, r"\bimplemented=1\b") + self.assertRegex(result.stdout, r"\bplanned=1\b") + self.assertRegex(result.stdout, r"\bgap=1\b") + self.assertRegex(result.stdout, r"\bblocked=1\b") + + def test_counts_only_implemented_flows_as_pass(self) -> None: + flows = [ + self.valid_flow("implemented-flow", "api", "FR-1", "implemented"), + self.valid_flow("planned-flow", "ui", "FR-2", "planned"), + self.valid_flow("gap-flow", "mcp", "FR-3", "gap"), + self.valid_flow("blocked-flow", "mcp", "FR-4", "blocked"), + ] + self.write_inventory(flows) + + result = self.run_validator(["FR-1", "FR-2", "FR-3", "FR-4"]) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertRegex(result.stdout, r"\bpass=1\b") + self.assertNotRegex(result.stdout, r"\bpass=4\b") + + def test_rejects_duplicate_id_unknown_enum_and_missing_required_fields(self) -> None: + base = self.valid_flow("flow-a", "api", "FR-1") + cases: list[tuple[str, list[dict[str, object]], str]] = [] + + duplicate = copy.deepcopy(base) + duplicate["user_outcome"] = "A distinct outcome with a duplicate identifier." + cases.append(("duplicate ID", [base, duplicate], "DUPLICATE_FLOW_ID")) + + unknown_status = copy.deepcopy(base) + unknown_status["status"] = "done" + cases.append(("unknown status", [unknown_status], "UNKNOWN_STATUS")) + + unknown_type = copy.deepcopy(base) + unknown_type["type"] = "worker" + cases.append(("unknown type", [unknown_type], "UNKNOWN_FLOW_TYPE")) + + no_owner = copy.deepcopy(base) + no_owner.pop("owner") + cases.append(("missing owner", [no_owner], "MISSING_OWNER")) + + no_outcome = copy.deepcopy(base) + no_outcome.pop("user_outcome") + cases.append(("missing required field", [no_outcome], "MISSING_REQUIRED_FIELD")) + + for label, flows, expected_code in cases: + with self.subTest(label=label): + self.write_inventory(flows) + result = self.run_validator(["FR-1"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"error[{expected_code}]", result.stderr) + + def test_rejects_missing_mandatory_requirement(self) -> None: + self.write_inventory([self.valid_flow("only-fr-one", "api", "FR-1")]) + + result = self.run_validator(["FR-1", "FR-46"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[MISSING_REQUIRED_FR]", result.stderr) + self.assertIn("FR-46", result.stderr) + + def test_rejects_missing_non_regular_and_escaping_evidence_links(self) -> None: + base = self.valid_flow("evidence-flow", "mcp", "FR-46") + outside = self.root.parent / f"{self.root.name}-outside.txt" + outside.write_text("outside\n", encoding="utf-8") + self.addCleanup(outside.unlink, missing_ok=True) + internal_target = self.write_evidence("evidence/automated/internal-target.txt") + internal_link = self.root / "evidence" / "automated" / "internal-link.txt" + internal_link.symlink_to(self.root / internal_target) + outside_link = self.root / "evidence" / "automated" / "outside-link.txt" + outside_link.symlink_to(outside) + broken_cases = [ + "evidence/automated/does-not-exist.txt", + "evidence/automated", + f"../{outside.name}", + str(outside), + "evidence/automated/internal-link.txt", + "evidence/automated/outside-link.txt", + ] + + for evidence_path in broken_cases: + with self.subTest(evidence_path=evidence_path): + flow = copy.deepcopy(base) + flow["evidence"] = { + "automated": [evidence_path], + "manual": ["evidence/manual/evidence-flow.md"], + } + self.write_inventory([flow]) + result = self.run_validator(["FR-46"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[BROKEN_EVIDENCE_LINK]", result.stderr) + + def test_report_is_deterministic_bounded_sorted_and_redacted(self) -> None: + secret_canary = "SECRET_CANARY_sk-live-4hQ9Vw8x" + flows = [] + for index in range(1_200, 0, -1): + flow = self.valid_flow( + f"invalid-flow-{index:04d}", + "api", + f"FR-{index}", + ) + flow["status"] = "unknown" + flow["user_outcome"] = f"{secret_canary}:{index}" + flows.append(flow) + self.write_inventory(flows) + raw_inventory = self.inventory_path.read_text(encoding="utf-8") + + first = self.run_validator(["FR-46"]) + second = self.run_validator(["FR-46"]) + + self.assertNotEqual(first.returncode, 0) + self.assertEqual(first.stderr, second.stderr) + self.assertLessEqual(len(first.stderr.encode("utf-8")), 64 * 1024) + self.assertNotIn(secret_canary, first.stderr) + self.assertNotIn(raw_inventory, first.stderr) + diagnostics = re.findall(r"error\[([^]]+)\] ([^:]+):", first.stderr) + self.assertTrue(diagnostics) + self.assertEqual(diagnostics[:-1], sorted(diagnostics[:-1])) + self.assertEqual(diagnostics[-1][0], "TRUNCATED") + self.assertNotRegex(first.stderr, r"invalid-flow-\d{4}") + + def test_rejects_malformed_documents_and_schema_contract_drift(self) -> None: + self.write_inventory([self.valid_flow("valid-flow", "api", "FR-46")]) + + self.inventory_path.write_text("{not-json", encoding="utf-8") + malformed_inventory = self.run_validator(["FR-46"]) + self.assertNotEqual(malformed_inventory.returncode, 0) + self.assertIn("error[INVALID_JSON]", malformed_inventory.stderr) + + self.write_inventory([self.valid_flow("valid-flow", "api", "FR-46")]) + self.schema_path.write_text("{not-json", encoding="utf-8") + malformed_schema = self.run_validator(["FR-46"], install_schema=False) + self.assertNotEqual(malformed_schema.returncode, 0) + self.assertIn("error[INVALID_SCHEMA]", malformed_schema.stderr) + + self.install_canonical_schema() + schema = json.loads(self.schema_path.read_text(encoding="utf-8")) + schema["$schema"] = "https://json-schema.org/draft/2019-09/schema" + self.schema_path.write_text(json.dumps(schema), encoding="utf-8") + drifted_schema = self.run_validator(["FR-46"], install_schema=False) + self.assertNotEqual(drifted_schema.returncode, 0) + self.assertIn("error[INVALID_SCHEMA_CONTRACT]", drifted_schema.stderr) + + def test_rejects_duplicate_nonstandard_recursive_and_large_integer_json(self) -> None: + malformed_documents = { + "duplicate member": ( + '{"schema_version":1,"schema_version":1,' + '"product":"crank-community","flows":[]}', + "INVALID_JSON", + ), + "non-standard number": ( + '{"schema_version":1,"product":NaN,"flows":[]}', + "INVALID_JSON", + ), + "recursive": ("[" * 100_000 + "0" + "]" * 100_000, "INVALID_JSON"), + "large integer": ("9" * 5_000, "INVALID_JSON"), + } + + for label, (content, expected_code) in malformed_documents.items(): + with self.subTest(label=label): + self.inventory_path.write_text(content, encoding="utf-8") + result = self.run_validator(["FR-46"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"error[{expected_code}]", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertLessEqual(len(result.stderr.encode("utf-8")), 64 * 1024) + + def test_schema_parity_rejects_required_type_ref_and_pattern_drift(self) -> None: + self.write_inventory([self.valid_flow("schema-parity", "api", "FR-46")]) + mutations = { + "root required missing": lambda schema: schema.pop("required"), + "root contract inversion": lambda schema: schema.update({"not": {}}), + "malformed evidence required": lambda schema: schema["$defs"]["evidence"].update( + {"required": [{}]} + ), + "flow ref drift": lambda schema: schema["properties"]["flows"]["items"].update( + {"$ref": "#/$defs/evidence"} + ), + "owner type drift": lambda schema: schema["$defs"]["flow"]["properties"][ + "owner" + ].update({"type": "integer"}), + "owner const drift": lambda schema: schema["$defs"]["flow"]["properties"][ + "owner" + ].update({"const": "impossible"}), + "nested applicator drift": lambda schema: schema["$defs"]["evidencePaths"][ + "items" + ].update({"anyOf": [{"type": "integer"}]}), + "evidence pattern drift": lambda schema: schema["$defs"]["evidencePaths"][ + "items" + ].update({"pattern": ".+"}), + } + + for label, mutate in mutations.items(): + with self.subTest(label=label): + self.install_canonical_schema() + schema = json.loads(self.schema_path.read_text(encoding="utf-8")) + mutate(schema) + self.schema_path.write_text(json.dumps(schema), encoding="utf-8") + result = self.run_validator(["FR-46"], install_schema=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[INVALID_SCHEMA_CONTRACT]", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + self.install_canonical_schema() + schema = json.loads(self.schema_path.read_text(encoding="utf-8")) + schema["$defs"]["flow"]["properties"]["status"]["enum"].reverse() + self.schema_path.write_text(json.dumps(schema), encoding="utf-8") + reordered_enum = self.run_validator(["FR-46"], install_schema=False) + self.assertEqual(reordered_enum.returncode, 0, reordered_enum.stderr) + + def test_rejects_boolean_schema_version_and_whitespace_required_text(self) -> None: + base = self.valid_flow("strict-scalars", "api", "FR-46") + payload_cases: list[tuple[str, dict[str, object], str]] = [] + + boolean_version = { + "schema_version": True, + "product": "crank-community", + "flows": [copy.deepcopy(base)], + } + payload_cases.append(("boolean inventory version", boolean_version, "MISSING_REQUIRED_FIELD")) + + whitespace_owner = copy.deepcopy(base) + whitespace_owner["owner"] = " \t\n " + payload_cases.append( + ( + "whitespace owner", + {"schema_version": 1, "product": "crank-community", "flows": [whitespace_owner]}, + "MISSING_OWNER", + ) + ) + + whitespace_outcome = copy.deepcopy(base) + whitespace_outcome["user_outcome"] = " \t\n " + payload_cases.append( + ( + "whitespace outcome", + {"schema_version": 1, "product": "crank-community", "flows": [whitespace_outcome]}, + "LIMIT_EXCEEDED", + ) + ) + + for label, payload, expected_code in payload_cases: + with self.subTest(label=label): + self.inventory_path.write_text(json.dumps(payload), encoding="utf-8") + result = self.run_validator(["FR-46"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"error[{expected_code}]", result.stderr) + + self.write_inventory([base]) + self.install_canonical_schema() + schema = json.loads(self.schema_path.read_text(encoding="utf-8")) + schema["properties"]["schema_version"]["const"] = True + self.schema_path.write_text(json.dumps(schema), encoding="utf-8") + schema_result = self.run_validator(["FR-46"], install_schema=False) + self.assertNotEqual(schema_result.returncode, 0) + self.assertIn("error[INVALID_SCHEMA_CONTRACT]", schema_result.stderr) + + def test_diagnostic_accumulation_is_capped_counted_and_redacted(self) -> None: + secret_canary = "SECRET_CANARY_unknown-field" + flow = self.valid_flow("bounded-diagnostics", "api", "FR-46") + for index in range(2_000): + flow[f"{secret_canary}-{index:04d}"] = True + self.write_inventory([flow]) + + result = self.run_validator(["FR-46"]) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[TRUNCATED] /: diagnostics omitted=1000", result.stderr) + self.assertEqual(result.stderr.count("error[UNKNOWN_FIELD]"), 1_000) + self.assertNotIn(secret_canary, result.stderr) + self.assertLessEqual(len(result.stderr.encode("utf-8")), 64 * 1024) + + def test_rejects_oversized_document_and_field_limits(self) -> None: + self.inventory_path.write_bytes(b" " * (4_194_304 + 1)) + oversized = self.run_validator(["FR-46"]) + self.assertNotEqual(oversized.returncode, 0) + self.assertIn("error[INPUT_TOO_LARGE]", oversized.stderr) + + flow = self.valid_flow("bounded-flow", "api", "FR-46") + flow["user_outcome"] = "x" * 4_097 + self.write_inventory([flow]) + overlong = self.run_validator(["FR-46"]) + self.assertNotEqual(overlong.returncode, 0) + self.assertIn("error[LIMIT_EXCEEDED]", overlong.stderr) + + def test_named_string_limits_cover_below_at_and_above_boundaries(self) -> None: + string_cases = [ + ("id", 128, lambda length: "a" * length), + ("owner", 256, lambda length: "o" * length), + ("user_outcome", 4_096, lambda length: "u" * length), + ("notes", 4_096, lambda length: "n" * length), + ("requirements", 128, lambda length: "FR-" + "1" * (length - 3)), + ] + + for field, limit, make_value in string_cases: + for delta in (-1, 0, 1): + with self.subTest(field=field, length=limit + delta): + safe_field = field.replace("_", "-") + flow = self.valid_flow(f"bounded-{safe_field}-{delta + 1}", "api", "FR-46") + value = make_value(limit + delta) + if field == "requirements": + flow[field] = [value] + required_frs = [value] + else: + flow[field] = value + required_frs = ["FR-46"] + self.write_inventory([flow]) + result = self.run_validator(required_frs) + if delta <= 0: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[LIMIT_EXCEEDED]", result.stderr) + + def test_collection_limits_cover_below_at_and_above_boundaries(self) -> None: + module = load_validator_module() + automated = self.write_evidence("evidence/shared/automated.txt") + manual = self.write_evidence("evidence/shared/manual.md") + + def direct_flow(index: int) -> dict[str, object]: + return { + "id": f"flow-{index:05d}", + "type": "api", + "requirements": ["FR-46"], + "user_outcome": "A bounded flow succeeds.", + "owner": "community-release", + "status": "implemented", + "capabilities": ["tools"], + "evidence": {"automated": [automated], "manual": [manual]}, + } + + all_flows = [direct_flow(index) for index in range(10_001)] + for count in (9_999, 10_000, 10_001): + with self.subTest(collection="flows", count=count): + inventory = { + "schema_version": 1, + "product": "crank-community", + "flows": all_flows[:count], + } + diagnostics, _ = module.validate_inventory(self.root, inventory, ["FR-46"]) + has_limit_error = any( + item.code == "LIMIT_EXCEEDED" and item.pointer == "/flows" + for item in diagnostics.ordered() + ) + self.assertEqual(has_limit_error, count > 10_000) + + for count in (63, 64, 65): + with self.subTest(collection="requirements", count=count): + flow = self.valid_flow(f"requirements-{count}", "api", "FR-1") + flow["requirements"] = [f"FR-{index}" for index in range(1, count + 1)] + self.write_inventory([flow]) + result = self.run_validator(["FR-1"]) + self.assertEqual(result.returncode == 0, count <= 64, result.stderr) + + evidence_paths = [ + self.write_evidence(f"evidence/bounds/path-{index:02d}.txt") + for index in range(65) + ] + for count in (63, 64, 65): + with self.subTest(collection="evidence", count=count): + flow = self.valid_flow(f"evidence-{count}", "api", "FR-46") + flow["evidence"] = { + "automated": evidence_paths[:count], + "manual": [manual], + } + self.write_inventory([flow]) + result = self.run_validator(["FR-46"]) + self.assertEqual(result.returncode == 0, count <= 64, result.stderr) + + for count in (15, 16, 17): + with self.subTest(collection="capability-bound", count=count): + diagnostics = module.validate_string_list( + [f"capability-{index}" for index in range(count)], + "/capabilities", + 16, + 128, + ) + has_limit_error = any(item.code == "LIMIT_EXCEEDED" for item in diagnostics) + self.assertEqual(has_limit_error, count > 16) + + def test_evidence_path_limit_covers_below_at_and_above_boundary(self) -> None: + def path_of_length(length: int) -> str: + segment_count = (length + 199) // 201 + character_count = length - (segment_count - 1) + lengths: list[int] = [] + remaining = character_count + for index in range(segment_count): + slots_left = segment_count - index - 1 + current = min(200, remaining - slots_left) + lengths.append(current) + remaining -= current + path = "/".join("p" * size for size in lengths) + self.assertEqual(len(path), length) + return path + + manual = self.write_evidence("evidence/manual/path-limit.md") + for length in (1_023, 1_024, 1_025): + with self.subTest(length=length): + evidence_path = path_of_length(length) + if length <= 1_024: + self.write_evidence(evidence_path) + flow = self.valid_flow(f"path-limit-{length}", "api", "FR-46") + flow["evidence"] = { + "automated": [evidence_path], + "manual": [manual], + } + self.write_inventory([flow]) + result = self.run_validator(["FR-46"]) + self.assertEqual(result.returncode == 0, length <= 1_024, result.stderr) + + def test_semantic_validator_enforces_schema_shape_uniqueness_and_fr_format(self) -> None: + cases: list[tuple[str, object, str]] = [] + + unknown_top = { + "schema_version": 1, + "product": "crank-community", + "flows": [self.valid_flow("unknown-top", "api", "FR-46")], + "unexpected": True, + } + cases.append(("unknown top-level field", unknown_top, "UNKNOWN_FIELD")) + + unknown_flow = self.valid_flow("unknown-flow", "api", "FR-46") + unknown_flow["unexpected"] = True + cases.append( + ( + "unknown flow field", + {"schema_version": 1, "product": "crank-community", "flows": [unknown_flow]}, + "UNKNOWN_FIELD", + ) + ) + + duplicate_requirement = self.valid_flow("duplicate-fr", "api", "FR-46") + duplicate_requirement["requirements"] = ["FR-46", "FR-46"] + cases.append( + ( + "duplicate requirement", + { + "schema_version": 1, + "product": "crank-community", + "flows": [duplicate_requirement], + }, + "DUPLICATE_LIST_ITEM", + ) + ) + + malformed_requirement = self.valid_flow("malformed-fr", "api", "FR-46") + malformed_requirement["requirements"] = ["FR-0"] + cases.append( + ( + "malformed requirement", + { + "schema_version": 1, + "product": "crank-community", + "flows": [malformed_requirement], + }, + "INVALID_FORMAT", + ) + ) + + unknown_evidence = self.valid_flow("unknown-evidence", "api", "FR-46") + evidence = unknown_evidence["evidence"] + self.assertIsInstance(evidence, dict) + evidence["unexpected"] = [] + cases.append( + ( + "unknown evidence field", + { + "schema_version": 1, + "product": "crank-community", + "flows": [unknown_evidence], + }, + "UNKNOWN_FIELD", + ) + ) + + for label, payload, expected_code in cases: + with self.subTest(label=label): + self.inventory_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + result = self.run_validator(["FR-46"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"error[{expected_code}]", result.stderr) + + def test_accepts_planned_modern_mcp_and_load_run_capabilities(self) -> None: + capabilities = ["resources", "prompts", "tasks", "load_runs"] + flows = [ + self.valid_flow( + f"planned-{capability.replace('_', '-')}", + "mcp" if capability != "load_runs" else "ui", + f"FR-{index}", + "planned", + [capability], + ) + for index, capability in enumerate(capabilities, start=47) + ] + self.write_inventory(flows) + + result = self.run_validator([f"FR-{index}" for index in range(47, 51)]) + + self.assertEqual(result.returncode, 0, result.stderr) + for capability in capabilities: + self.assertNotIn(f"error[FORBIDDEN_COMMUNITY_CAPABILITY]: {capability}", result.stderr) + + def test_rejects_forbidden_community_capabilities_despite_allow_text(self) -> None: + forbidden = [ + "multi_workspace", # community-scope: allow=multi-workspace + "enterprise_rbac", # community-scope: allow=enterprise + "sso", # community-scope: allow=sso + "non_rest_upstream", # community-scope: allow=non-rest-upstream + "arbitrary_distributed_load_targets", # community-scope: allow=distributed-load-targets + ] + + for index, capability in enumerate(forbidden, start=1): + with self.subTest(capability=capability): + flow = self.valid_flow( + f"forbidden-{index}", + "api", + "FR-46", + "planned", + [capability], + ) + flow["notes"] = f"community-scope: allow={capability}" + self.write_inventory([flow]) + result = self.run_validator(["FR-46"]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("error[FORBIDDEN_COMMUNITY_CAPABILITY]", result.stderr) + self.assertIn(capability, result.stderr) + + def test_scope_scanner_receives_inventory_files_explicitly_and_cannot_be_bypassed(self) -> None: + clean_inventory = self.valid_flow( + "planned-resources", + "mcp", + "FR-47", + "planned", + ["resources"], + ) + self.write_inventory([clean_inventory]) + explicit_files = [ + str(self.inventory_path.relative_to(self.root)), + "docs/new-untracked-scope-note.md", + ] + note = self.root / explicit_files[1] + note.write_text("Community Resources remain planned.\n", encoding="utf-8") + + clean = subprocess.run( + [ + sys.executable, + str(COMMUNITY_CHECKER), + "--root", + str(self.root), + "--files", + *explicit_files, + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + self.assertEqual(clean.returncode, 0, clean.stderr) + self.assertIn("2 files scanned", clean.stdout) + + note.write_text( + "community-scope: allow=enterprise\n" + "enterprise_rbac remains outside Community scope.\n", # community-scope: allow=enterprise + encoding="utf-8", + ) + rejected = subprocess.run( + [ + sys.executable, + str(COMMUNITY_CHECKER), + "--root", + str(self.root), + "--files", + *explicit_files, + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("docs/new-untracked-scope-note.md:2", rejected.stderr) + self.assertIn("forbidden marker", rejected.stderr) + self.assertIn("enterprise", rejected.stderr) # community-scope: allow=enterprise + + def test_canonical_inventory_seed_does_not_promote_planned_work(self) -> None: + for path in [ + CANONICAL_INVENTORY, + CANONICAL_SCHEMA, + ]: + self.assertTrue(path.is_file(), f"Missing canonical artifact: {path}") + + inventory = json.loads(CANONICAL_INVENTORY.read_text(encoding="utf-8")) + schema = json.loads(CANONICAL_SCHEMA.read_text(encoding="utf-8")) + flow_schema = schema["$defs"]["flow"] + evidence_paths = schema["$defs"]["evidencePaths"] + self.assertEqual( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema", + ) + self.assertEqual(schema["properties"]["flows"]["maxItems"], 10_000) + self.assertEqual( + set(flow_schema["properties"]["type"]["enum"]), + {"ui", "api", "mcp"}, + ) + self.assertEqual( + set(flow_schema["properties"]["status"]["enum"]), + {"implemented", "planned", "gap", "blocked"}, + ) + self.assertEqual(evidence_paths["minItems"], 1) + self.assertEqual(evidence_paths["maxItems"], 64) + target_capabilities = {"resources", "prompts", "tasks", "load_runs"} + matching_flows = [ + flow + for flow in inventory["flows"] + if target_capabilities.intersection(flow.get("capabilities", [])) + ] + observed_capabilities = { + capability + for flow in matching_flows + for capability in flow.get("capabilities", []) + if capability in target_capabilities + } + + self.assertEqual(observed_capabilities, target_capabilities) + self.assertTrue(matching_flows) + self.assertTrue( + all(flow["status"] != "implemented" for flow in matching_flows), + "Story 1.1 must not claim target capabilities are implemented.", + ) + + def test_canonical_inventory_covers_all_requirements_and_required_surfaces(self) -> None: + inventory = json.loads(CANONICAL_INVENTORY.read_text(encoding="utf-8")) + required_surfaces = json.loads(REQUIRED_SURFACES.read_text(encoding="utf-8")) + flows = {flow["id"]: flow for flow in inventory["flows"]} + observed_requirements = { + requirement for flow in flows.values() for requirement in flow["requirements"] + } + + self.assertEqual( + {f"FR-{number}" for number in range(1, 55)} - observed_requirements, + set(), + ) + self.assertEqual( + set(required_surfaces["required_flow_ids"]) - set(flows), + set(), + ) + for flow_id in required_surfaces["required_flow_ids"]: + flow = flows[flow_id] + self.assertIn(flow["status"], {"implemented", "blocked"}) + for evidence_kind in ("automated", "manual"): + for evidence_path in flow["evidence"][evidence_kind]: + resolved = ROOT / evidence_path + self.assertTrue(resolved.is_file(), evidence_path) + self.assertFalse(resolved.is_symlink(), evidence_path) + + def test_tracked_documentation_describes_planned_scope(self) -> None: + for documentation in [ + COMMUNITY_README, + DOCS_INDEX, + COMMUNITY_INTRO, + COMMUNITY_README_EN, + ]: + text = documentation.read_text(encoding="utf-8") + self.assertIn("docs/capability-inventory.json", text) + self.assertRegex(text.lower(), r"\b(planned|gap|blocked)\b") + + def test_no_story_acceptance_scenario_remains_skipped(self) -> None: + for name, method in vars(type(self)).items(): + if not name.startswith("test_") or name == self._testMethodName: + continue + self.assertFalse( + getattr(method, "__unittest_skip__", False), + f"Story acceptance scenario remains skipped: {name}", + ) + + def test_canonical_validator_is_a_ci_and_release_prerequisite(self) -> None: + justfile = JUSTFILE.read_text(encoding="utf-8") + ci = CI_WORKFLOW.read_text(encoding="utf-8") + release = RELEASE_WORKFLOW.read_text(encoding="utf-8") + command = "scripts/validate-capability-inventory.py" + + self.assertIn("capability-inventory-check:", justfile) + self.assertIn("just capability-inventory-check", justfile) + self.assertLess(ci.index("Run tooling unit tests"), ci.index(command)) + self.assertLess(ci.index(command), ci.index("Check Community scope")) + self.assertIn("python3 --version", release) + self.assertLess(release.index("Run tooling unit tests"), release.index(command)) + self.assertLess(release.index(command), release.index("Check Community scope")) + self.assertLess(release.index("Check Community scope"), release.index("Build release binaries")) + + +if __name__ == "__main__": + unittest.main()