diff --git a/Cargo.lock b/Cargo.lock index afbad4a..142d978 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,6 +433,7 @@ dependencies = [ name = "crank-core" version = "0.1.0" dependencies = [ + "async-trait", "serde", "serde_json", "serde_yaml", diff --git a/TASKS.md b/TASKS.md index 8fa34cf..778ef95 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,6 +2,60 @@ ## Current +### `feat/optional-cache-layer` + +Status: in_progress + +Goal: +- добавить в продукт optional cache/coordination layer с `Valkey/Redis` backend без обязательной зависимости от него. + +Main code areas: +- `crates/crank-core` +- `crates/crank-runtime` +- `apps/admin-api` +- `apps/mcp-server` +- `deploy/community/*` +- `docs/runtime-config.md` +- `docs/product-editions.md` +- `docs/commercial-boundaries.md` + +Implementation slices: +1. Зафиксировать public cache contracts: + - response cache + - rate-limit store + - token replay / one-time token store + - ephemeral coordination state +2. Подготовить Community fallback path: + - без `Valkey/Redis` все должно работать + - degraded mode documented explicitly +3. Добавить optional `Valkey` service в Community deployment manifests как рекомендованный, но не обязательный runtime component. +4. Подготовить `Valkey/Redis` backend для: + - managed Cloud default + - enterprise optional cluster-aware deployment +5. Протянуть capability/config model так, чтобы решение "использовать или нет внешний cache" принимал оператор, а не кодовая база. + +DoD: +- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими; +- с `Valkey/Redis` платформа умеет снижать runtime/load pressure и хранить ephemeral coordination state; +- `Cloud` может использовать shared cache layer по умолчанию; +- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант; +- runtime/docs/manifests не делают cache обязательным для базового запуска. + +Verification: +- targeted runtime/admin/mcp tests for cache contracts and fallback mode; +- deploy manifest validation. + +Progress: +- done: + - product/docs/runtime plan already define cache layer as optional and edition-aware + - public cache contracts and runtime cache config now exist in `crank-core` / `crank-runtime` +- pending: + - Community fallback implementations + - optional `Valkey` in Community manifests + - external cache backend for managed / enterprise contours + +## Planned + ### `feat/open-core-repo-boundary` Status: in_progress @@ -49,8 +103,6 @@ Progress: - remaining extension seams and packaging split still need to move from planning into concrete `crank-community` / `crank-enterprise` / `crank-cloud` manifests - next management gate is to actually create the `3` target repositories before physical split starts -## Planned - ### `feat/enterprise-access-governance` Status: ready @@ -123,45 +175,6 @@ DoD: - Enterprise и Cloud используют private delivery path; - коммерческий код не требуется публиковать в open source ради поставки. -### `feat/optional-cache-layer` - -Status: ready - -Goal: -- добавить в продукт optional cache/coordination layer с `Valkey/Redis` backend без обязательной зависимости от него. - -Main code areas: -- `crates/crank-core` -- `crates/crank-runtime` -- `apps/admin-api` -- `apps/mcp-server` -- `deploy/community/*` -- `docs/runtime-config.md` -- `docs/product-editions.md` -- `docs/commercial-boundaries.md` - -Implementation slices: -1. Зафиксировать public cache contracts: - - response cache - - rate-limit store - - token replay / one-time token store - - ephemeral coordination state -2. Подготовить Community fallback path: - - без `Valkey/Redis` все должно работать - - degraded mode documented explicitly -3. Добавить optional `Valkey` service в Community deployment manifests как рекомендованный, но не обязательный runtime component. -4. Подготовить `Valkey/Redis` backend для: - - managed Cloud default - - enterprise optional cluster-aware deployment -5. Протянуть capability/config model так, чтобы решение "использовать или нет внешний cache" принимал оператор, а не кодовая база. - -DoD: -- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими; -- с `Valkey/Redis` платформа умеет снижать runtime/load pressure и хранить ephemeral coordination state; -- `Cloud` может использовать shared cache layer по умолчанию; -- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант; -- runtime/docs/manifests не делают cache обязательным для базового запуска. - ### `feat/live-authenticated-staging-entry` Status: ready diff --git a/crates/crank-core/Cargo.toml b/crates/crank-core/Cargo.toml index 483cece..60af9cf 100644 --- a/crates/crank-core/Cargo.toml +++ b/crates/crank-core/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +async-trait = "0.1" serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/crank-core/src/cache.rs b/crates/crank-core/src/cache.rs new file mode 100644 index 0000000..d3dd98b --- /dev/null +++ b/crates/crank-core/src/cache.rs @@ -0,0 +1,200 @@ +use std::{fmt, str::FromStr, time::Duration}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CacheBackend { + Memory, + Valkey, + Redis, +} + +impl CacheBackend { + pub fn is_external(self) -> bool { + matches!(self, Self::Valkey | Self::Redis) + } +} + +impl fmt::Display for CacheBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Memory => "memory", + Self::Valkey => "valkey", + Self::Redis => "redis", + }; + f.write_str(value) + } +} + +impl FromStr for CacheBackend { + type Err = ParseCacheBackendError; + + fn from_str(value: &str) -> Result { + match value { + "memory" => Ok(Self::Memory), + "valkey" => Ok(Self::Valkey), + "redis" => Ok(Self::Redis), + _ => Err(ParseCacheBackendError { + value: value.to_owned(), + }), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CacheScope { + Response, + RateLimit, + ReplayGuard, + Coordination, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedHeader { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedResponse { + pub status: u16, + pub headers: Vec, + pub body: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimitBucketState { + pub tokens_micros: u64, + pub last_refill_unix_ms: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplayGuardStatus { + Fresh, + AlreadySeen, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoordinationStateValue { + pub payload: Value, +} + +#[async_trait] +pub trait ResponseCacheStore: Send + Sync { + async fn get(&self, key: &str) -> Result, CacheStoreError>; + async fn put( + &self, + key: &str, + value: CachedResponse, + ttl: Duration, + ) -> Result<(), CacheStoreError>; + async fn delete(&self, key: &str) -> Result<(), CacheStoreError>; +} + +#[async_trait] +pub trait RateLimitStateStore: Send + Sync { + async fn get_bucket(&self, key: &str) -> Result, CacheStoreError>; + async fn put_bucket( + &self, + key: &str, + value: RateLimitBucketState, + ttl: Duration, + ) -> Result<(), CacheStoreError>; + async fn delete_bucket(&self, key: &str) -> Result<(), CacheStoreError>; +} + +#[async_trait] +pub trait ReplayGuardStore: Send + Sync { + async fn mark_seen( + &self, + key: &str, + ttl: Duration, + ) -> Result; + async fn clear(&self, key: &str) -> Result<(), CacheStoreError>; +} + +#[async_trait] +pub trait CoordinationStateStore: Send + Sync { + async fn get_value( + &self, + scope: CacheScope, + key: &str, + ) -> Result, CacheStoreError>; + async fn put_value( + &self, + scope: CacheScope, + key: &str, + value: CoordinationStateValue, + ttl: Duration, + ) -> Result<(), CacheStoreError>; + async fn delete_value(&self, scope: CacheScope, key: &str) -> Result<(), CacheStoreError>; +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CacheStoreError { + #[error("cache backend is unavailable: {message}")] + Unavailable { message: String }, + #[error("cache key is invalid: {message}")] + InvalidKey { message: String }, + #[error("cache value serialization failed: {message}")] + Serialization { message: String }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +#[error("unsupported cache backend {value}")] +pub struct ParseCacheBackendError { + pub value: String, +} + +#[cfg(test)] +mod tests { + use super::{CacheBackend, CacheScope, ParseCacheBackendError, ReplayGuardStatus}; + + #[test] + fn cache_backend_roundtrip_is_stable() { + for backend in [ + CacheBackend::Memory, + CacheBackend::Valkey, + CacheBackend::Redis, + ] { + let encoded = backend.to_string(); + let decoded = encoded.parse::().unwrap(); + assert_eq!(decoded, backend); + } + } + + #[test] + fn rejects_unknown_cache_backend() { + assert_eq!( + "memcached".parse::(), + Err(ParseCacheBackendError { + value: "memcached".to_owned() + }) + ); + } + + #[test] + fn distinguishes_external_cache_backends() { + assert!(!CacheBackend::Memory.is_external()); + assert!(CacheBackend::Valkey.is_external()); + assert!(CacheBackend::Redis.is_external()); + } + + #[test] + fn serialized_contracts_use_snake_case_values() { + assert_eq!( + serde_json::to_string(&CacheScope::ReplayGuard).unwrap(), + "\"replay_guard\"" + ); + assert_eq!( + serde_json::to_string(&ReplayGuardStatus::AlreadySeen).unwrap(), + "\"already_seen\"" + ); + } +} diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index a53a94f..28b3cd8 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod access; pub mod agent; pub mod auth; +pub mod cache; pub mod edition; pub mod ids; pub mod observability; @@ -22,6 +23,11 @@ pub use auth::{ BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, }; +pub use cache::{ + CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse, + CoordinationStateStore, CoordinationStateValue, ParseCacheBackendError, RateLimitBucketState, + RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore, +}; pub use edition::{ EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition, }; diff --git a/crates/crank-runtime/src/cache.rs b/crates/crank-runtime/src/cache.rs new file mode 100644 index 0000000..0c00152 --- /dev/null +++ b/crates/crank-runtime/src/cache.rs @@ -0,0 +1,180 @@ +use std::{env, num::ParseIntError}; + +use crank_core::CacheBackend; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeCacheConfig { + pub backend: CacheBackend, + pub url: Option, + pub default_ttl_ms: Option, +} + +impl Default for RuntimeCacheConfig { + fn default() -> Self { + 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")?; + + if backend.is_external() && url.is_none() { + return Err(RuntimeCacheConfigError::MissingUrl { backend }); + } + + Ok(Self { + backend, + url, + default_ttl_ms, + }) + } +} + +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 value = raw + .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 }, +} + +#[cfg(test)] +mod tests { + use crank_core::CacheBackend; + + use super::{RuntimeCacheConfig, RuntimeCacheConfigError}; + + #[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 loads_valkey_config_from_env() { + 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(); + + 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)); + + unsafe { + std::env::remove_var("CRANK_CACHE_BACKEND"); + std::env::remove_var("CRANK_CACHE_URL"); + std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS"); + } + } + + #[test] + fn rejects_external_backend_without_url() { + unsafe { + std::env::set_var("CRANK_CACHE_BACKEND", "redis"); + std::env::remove_var("CRANK_CACHE_URL"); + } + + let error = RuntimeCacheConfig::from_env().unwrap_err(); + + assert!(matches!( + error, + RuntimeCacheConfigError::MissingUrl { + backend: CacheBackend::Redis + } + )); + + unsafe { + std::env::remove_var("CRANK_CACHE_BACKEND"); + } + } + + #[test] + fn rejects_zero_ttl() { + 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" + } + )); + + unsafe { + std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS"); + } + } +} diff --git a/crates/crank-runtime/src/lib.rs b/crates/crank-runtime/src/lib.rs index 463ea99..7060992 100644 --- a/crates/crank-runtime/src/lib.rs +++ b/crates/crank-runtime/src/lib.rs @@ -1,5 +1,6 @@ mod aggregation; mod auth; +mod cache; mod error; mod executor; mod limits; @@ -11,6 +12,7 @@ mod secret_crypto; mod streaming; pub use auth::ResolvedAuth; +pub use cache::{RuntimeCacheConfig, RuntimeCacheConfigError}; pub use error::RuntimeError; pub use executor::RuntimeExecutor; pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};