diff --git a/TASKS.md b/TASKS.md index fe1c855..499666c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -39,6 +39,7 @@ Verification: Progress: - done: + - public target repository `crank-community` now exists and can receive bootstrap templates once the remaining private repositories are created - gate for creating `3` target repositories is already documented before any physical commercial split - canonical Community deployment manifest and env template now live under `deploy/community/*`, and public deploy uses that manifest instead of the root compose file - CI, README, runtime/deploy smoke docs now point to `deploy/community/*` as the Community delivery source of truth @@ -46,8 +47,12 @@ Progress: - explicit repository split map now defines what goes to `crank-community`, `crank-enterprise`, and `crank-cloud` - `Community` is now fixed as `REST-only` in product docs, capability model, backend validation, demo seed, and UI protocol expectations - bootstrap templates now exist for `crank-community`, `crank-enterprise`, and `crank-cloud`, including initial README and workflow skeletons + - `crank-runtime` now has protocol feature seams, and the runtime crate compiles with `--no-default-features` as a `REST-only` base - pending: - - next management gate is to actually create the `3` target repositories and move these bootstrap templates into their roots before physical split starts + - next management gate is to create the remaining private target repositories: + - `crank-enterprise` + - `crank-cloud` + - after all `3` repositories exist, move the prepared bootstrap templates into their roots before physical split starts ## Planned diff --git a/crates/crank-runtime/Cargo.toml b/crates/crank-runtime/Cargo.toml index 7c14717..8f4a0a4 100644 --- a/crates/crank-runtime/Cargo.toml +++ b/crates/crank-runtime/Cargo.toml @@ -5,15 +5,27 @@ license.workspace = true rust-version.workspace = true version.workspace = true +[features] +default = [ + "protocol-graphql", + "protocol-grpc", + "protocol-soap", + "protocol-websocket", +] +protocol-graphql = ["dep:crank-adapter-graphql"] +protocol-grpc = ["dep:crank-adapter-grpc"] +protocol-soap = ["dep:crank-adapter-soap"] +protocol-websocket = ["dep:crank-adapter-websocket"] + [dependencies] aes-gcm.workspace = true async-trait = "0.1" base64.workspace = true -crank-adapter-graphql = { path = "../crank-adapter-graphql" } -crank-adapter-grpc = { path = "../crank-adapter-grpc" } +crank-adapter-graphql = { path = "../crank-adapter-graphql", optional = true } +crank-adapter-grpc = { path = "../crank-adapter-grpc", optional = true } crank-adapter-rest = { path = "../crank-adapter-rest" } -crank-adapter-soap = { path = "../crank-adapter-soap" } -crank-adapter-websocket = { path = "../crank-adapter-websocket" } +crank-adapter-soap = { path = "../crank-adapter-soap", optional = true } +crank-adapter-websocket = { path = "../crank-adapter-websocket", optional = true } crank-core = { path = "../crank-core" } crank-mapping = { path = "../crank-mapping" } crank-schema = { path = "../crank-schema" } diff --git a/crates/crank-runtime/src/error.rs b/crates/crank-runtime/src/error.rs index 25fcfbc..f0dfcea 100644 --- a/crates/crank-runtime/src/error.rs +++ b/crates/crank-runtime/src/error.rs @@ -1,29 +1,34 @@ -use crank_adapter_graphql::GraphqlAdapterError; -use crank_adapter_grpc::GrpcAdapterError; use crank_adapter_rest::RestAdapterError; -use crank_adapter_soap::SoapAdapterError; -use crank_adapter_websocket::WebsocketAdapterError; use crank_core::{ExecutionMode, Protocol}; use crank_mapping::MappingError; use crank_schema::SchemaError; use thiserror::Error; +#[cfg(feature = "protocol-graphql")] +use crank_adapter_graphql::GraphqlAdapterError; +#[cfg(feature = "protocol-grpc")] +use crank_adapter_grpc::GrpcAdapterError; +#[cfg(feature = "protocol-soap")] +use crank_adapter_soap::SoapAdapterError; +#[cfg(feature = "protocol-websocket")] +use crank_adapter_websocket::WebsocketAdapterError; + #[derive(Debug, Error)] pub enum RuntimeError { #[error(transparent)] Schema(#[from] SchemaError), #[error(transparent)] Mapping(#[from] MappingError), - #[error(transparent)] - GraphqlAdapter(#[from] GraphqlAdapterError), - #[error(transparent)] - GrpcAdapter(#[from] GrpcAdapterError), + #[error("{0}")] + GraphqlAdapter(String), + #[error("{0}")] + GrpcAdapter(String), #[error(transparent)] RestAdapter(#[from] RestAdapterError), - #[error(transparent)] - SoapAdapter(#[from] SoapAdapterError), - #[error(transparent)] - WebsocketAdapter(#[from] WebsocketAdapterError), + #[error("{0}")] + SoapAdapter(String), + #[error("{0}")] + WebsocketAdapter(String), #[error("protocol {protocol:?} is not supported by runtime")] UnsupportedProtocol { protocol: Protocol }, #[error("operation {operation_id} does not define streaming config")] @@ -53,3 +58,31 @@ pub enum RuntimeError { details: String, }, } + +#[cfg(feature = "protocol-graphql")] +impl From for RuntimeError { + fn from(value: GraphqlAdapterError) -> Self { + Self::GraphqlAdapter(value.to_string()) + } +} + +#[cfg(feature = "protocol-grpc")] +impl From for RuntimeError { + fn from(value: GrpcAdapterError) -> Self { + Self::GrpcAdapter(value.to_string()) + } +} + +#[cfg(feature = "protocol-soap")] +impl From for RuntimeError { + fn from(value: SoapAdapterError) -> Self { + Self::SoapAdapter(value.to_string()) + } +} + +#[cfg(feature = "protocol-websocket")] +impl From for RuntimeError { + fn from(value: WebsocketAdapterError) -> Self { + Self::WebsocketAdapter(value.to_string()) + } +} diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index fb97ea9..166e5d5 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -3,11 +3,7 @@ use std::sync::Arc; use std::time::Duration; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; -use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest}; use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest}; -use crank_adapter_soap::{SoapAdapter, SoapRequest}; -use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest}; use crank_core::{ CachedHeader, CachedResponse, ExecutionMode, HttpMethod, ResponseCacheStore, Target, TransportBehavior, @@ -22,12 +18,25 @@ use crate::{ RuntimeRequestContext, WindowExecutionResult, }; +#[cfg(feature = "protocol-graphql")] +use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; +#[cfg(feature = "protocol-grpc")] +use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest}; +#[cfg(feature = "protocol-soap")] +use crank_adapter_soap::{SoapAdapter, SoapRequest}; +#[cfg(feature = "protocol-websocket")] +use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest}; + #[derive(Clone)] pub struct RuntimeExecutor { + #[cfg(feature = "protocol-graphql")] graphql_adapter: GraphqlAdapter, + #[cfg(feature = "protocol-grpc")] grpc_adapter: GrpcAdapter, rest_adapter: RestAdapter, + #[cfg(feature = "protocol-soap")] soap_adapter: SoapAdapter, + #[cfg(feature = "protocol-websocket")] websocket_adapter: WebsocketAdapter, limits: RuntimeLimits, unary_limiter: Arc, @@ -50,10 +59,14 @@ impl RuntimeExecutor { pub fn with_limits(limits: RuntimeLimits) -> Self { Self { + #[cfg(feature = "protocol-graphql")] graphql_adapter: GraphqlAdapter::new(), + #[cfg(feature = "protocol-grpc")] grpc_adapter: GrpcAdapter::new(), rest_adapter: RestAdapter::new(), + #[cfg(feature = "protocol-soap")] soap_adapter: SoapAdapter::new(), + #[cfg(feature = "protocol-websocket")] websocket_adapter: WebsocketAdapter::new(), unary_limiter: Arc::new(Semaphore::new(limits.max_concurrent_unary)), window_limiter: Arc::new(Semaphore::new(limits.max_concurrent_window)), @@ -312,47 +325,67 @@ impl RuntimeExecutor { let context_headers = runtime_context_headers(request_context); match &operation.target { Target::Grpc(target) => { - let request = GrpcRequest { - headers: merge_headers( - &BTreeMap::new(), - &operation.execution_config.headers, - &prepared_request.headers, - &context_headers, - ), - body: prepared_request - .grpc - .clone() - .unwrap_or(Value::Object(Map::new())), - timeout_ms: operation.execution_config.timeout_ms, - }; - let response = self.grpc_adapter.execute(target, &request).await?; + #[cfg(not(feature = "protocol-grpc"))] + { + let _ = target; + return Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, + }); + } + #[cfg(feature = "protocol-grpc")] + { + let request = GrpcRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + &context_headers, + ), + body: prepared_request + .grpc + .clone() + .unwrap_or(Value::Object(Map::new())), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.grpc_adapter.execute(target, &request).await?; - Ok(AdapterResponse { - status_code: response.status_code, - headers: response.headers, - body: response.body.clone(), - data: response.body, - }) + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body.clone(), + data: response.body, + }) + } } Target::Graphql(target) => { - let request = GraphqlRequest { - headers: merge_headers( - &BTreeMap::new(), - &operation.execution_config.headers, - &prepared_request.headers, - &context_headers, - ), - variables: prepared_request.variables.clone(), - timeout_ms: operation.execution_config.timeout_ms, - }; - let response = self.graphql_adapter.execute(target, &request).await?; + #[cfg(not(feature = "protocol-graphql"))] + { + let _ = target; + return Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, + }); + } + #[cfg(feature = "protocol-graphql")] + { + let request = GraphqlRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + &context_headers, + ), + variables: prepared_request.variables.clone(), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.graphql_adapter.execute(target, &request).await?; - Ok(AdapterResponse { - status_code: response.status_code, - headers: response.headers, - body: response.body, - data: response.data, - }) + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: response.data, + }) + } } Target::Rest(target) => { let request = RestRequest { @@ -377,27 +410,37 @@ impl RuntimeExecutor { }) } Target::Soap(target) => { - let request = SoapRequest { - headers: merge_headers( - &BTreeMap::new(), - &operation.execution_config.headers, - &prepared_request.headers, - &context_headers, - ), - body: prepared_request - .body - .clone() - .unwrap_or(Value::Object(Map::new())), - timeout_ms: operation.execution_config.timeout_ms, - }; - let response = self.soap_adapter.execute(target, &request).await?; + #[cfg(not(feature = "protocol-soap"))] + { + let _ = target; + Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, + }) + } + #[cfg(feature = "protocol-soap")] + { + let request = SoapRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + &context_headers, + ), + body: prepared_request + .body + .clone() + .unwrap_or(Value::Object(Map::new())), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.soap_adapter.execute(target, &request).await?; - Ok(AdapterResponse { - status_code: response.status_code, - headers: response.headers, - body: response.body, - data: Value::Null, - }) + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } } Target::Websocket(_) => Err(RuntimeError::UnsupportedExecutionMode { operation_id: operation.operation_id.as_str().to_owned(), @@ -449,79 +492,99 @@ impl RuntimeExecutor { }) } Target::Grpc(target) => { - let Some(streaming) = operation.execution_config.streaming.as_ref() else { - return Err(RuntimeError::MissingStreamingConfig { - operation_id: operation.operation_id.as_str().to_owned(), + #[cfg(not(feature = "protocol-grpc"))] + { + let _ = target; + return Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, }); - }; - let request = GrpcWindowRequest { - request: GrpcRequest { + } + #[cfg(feature = "protocol-grpc")] + { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + let request = GrpcWindowRequest { + request: GrpcRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + &context_headers, + ), + body: prepared_request + .grpc + .clone() + .unwrap_or(Value::Object(Map::new())), + timeout_ms: streaming + .upstream_timeout_ms + .unwrap_or(operation.execution_config.timeout_ms), + }, + window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), + max_items: streaming.max_items.map(|value| value.saturating_add(1)), + }; + let response = self.grpc_adapter.execute_window(target, &request).await?; + + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } + } + Target::Websocket(target) => { + #[cfg(not(feature = "protocol-websocket"))] + { + let _ = target; + Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, + }) + } + #[cfg(feature = "protocol-websocket")] + { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + let websocket_options = operation + .execution_config + .protocol_options + .as_ref() + .and_then(|options| options.websocket.as_ref()); + let request = WebsocketWindowRequest { headers: merge_headers( &BTreeMap::new(), &operation.execution_config.headers, &prepared_request.headers, &context_headers, ), - body: prepared_request - .grpc - .clone() - .unwrap_or(Value::Object(Map::new())), - timeout_ms: streaming - .upstream_timeout_ms - .unwrap_or(operation.execution_config.timeout_ms), - }, - window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), - max_items: streaming.max_items.map(|value| value.saturating_add(1)), - }; - let response = self.grpc_adapter.execute_window(target, &request).await?; + window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), + max_items: streaming.max_items.map(|value| value.saturating_add(1)), + heartbeat_interval_ms: websocket_options + .and_then(|options| options.heartbeat_interval_ms), + reconnect_max_attempts: websocket_options + .and_then(|options| options.reconnect_max_attempts) + .unwrap_or_default(), + reconnect_backoff_ms: websocket_options + .and_then(|options| options.reconnect_backoff_ms) + .unwrap_or_default(), + }; + let response = self + .websocket_adapter + .execute_window(target, &request) + .await?; - Ok(AdapterResponse { - status_code: response.status_code, - headers: response.headers, - body: response.body, - data: Value::Null, - }) - } - Target::Websocket(target) => { - let Some(streaming) = operation.execution_config.streaming.as_ref() else { - return Err(RuntimeError::MissingStreamingConfig { - operation_id: operation.operation_id.as_str().to_owned(), - }); - }; - let websocket_options = operation - .execution_config - .protocol_options - .as_ref() - .and_then(|options| options.websocket.as_ref()); - let request = WebsocketWindowRequest { - headers: merge_headers( - &BTreeMap::new(), - &operation.execution_config.headers, - &prepared_request.headers, - &context_headers, - ), - window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), - max_items: streaming.max_items.map(|value| value.saturating_add(1)), - heartbeat_interval_ms: websocket_options - .and_then(|options| options.heartbeat_interval_ms), - reconnect_max_attempts: websocket_options - .and_then(|options| options.reconnect_max_attempts) - .unwrap_or_default(), - reconnect_backoff_ms: websocket_options - .and_then(|options| options.reconnect_backoff_ms) - .unwrap_or_default(), - }; - let response = self - .websocket_adapter - .execute_window(target, &request) - .await?; - - Ok(AdapterResponse { - status_code: response.status_code, - headers: response.headers, - body: response.body, - data: Value::Null, - }) + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } } Target::Soap(_) => Err(RuntimeError::UnsupportedExecutionMode { operation_id: operation.operation_id.as_str().to_owned(), diff --git a/docs/commercial-boundaries.md b/docs/commercial-boundaries.md index 74a9a90..9c96123 100644 --- a/docs/commercial-boundaries.md +++ b/docs/commercial-boundaries.md @@ -79,6 +79,7 @@ - создание `crank-community`, `crank-enterprise` и `crank-cloud` должно быть отдельным осознанным шагом; - до этого момента в текущем репозитории нужно завершить capability model, extension seams и public contracts; - физическое вынесение private code нельзя начинать раньше, чем эти три целевых repositories созданы и для них определены delivery boundaries. +- на текущий момент public repository `crank-community` уже создан; следующий обязательный внешний шаг — создание private repositories `crank-enterprise` и `crank-cloud`. ## 6. Техническая стратегия разделения @@ -109,6 +110,12 @@ - cache store abstraction; - feature availability checks. +Дополнительно для runtime layer: + +- premium protocol adapters должны подключаться как feature-gated seams; +- `REST`-база должна собираться без `GraphQL/gRPC/SOAP/WebSocket` crates; +- `UnsupportedProtocol` должен оставаться публичным fallback contract для disabled protocol paths. + Private code должен подключаться как реализация этих контрактов, а не как условные ветки по всему коду Community. ### 6.3. Server-side enforcement diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 5536eb8..4bb3909 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -33,6 +33,7 @@ - старые review-файлы не требуются как отдельный источник истины. - canonical Community deployment manifest и env template вынесены в `deploy/community/*`; - public CI/CD delivery path использует именно `deploy/community/*` как source of truth для Community поставки. +- `crank-runtime` уже имеет feature seams для premium protocol adapters, так что Community base можно собирать как `REST-only` runtime foundation. ## 4. Этап 2. Community machine access completion @@ -144,6 +145,13 @@ - `crank-enterprise` - `crank-cloud` +Текущий статус: + +- `crank-community` уже создан; +- до начала physical split остаются private repositories: + - `crank-enterprise` + - `crank-cloud` + Bootstrap templates для этого шага уже должны быть подготовлены заранее в текущем репозитории: - `templates/repositories/crank-community/*` diff --git a/docs/module-decomposition.md b/docs/module-decomposition.md index 73db221..8e8860e 100644 --- a/docs/module-decomposition.md +++ b/docs/module-decomposition.md @@ -119,6 +119,11 @@ - запись invocation events; - возврат нормализованного результата. +Дополнительное правило для split: + +- `crank-runtime` должен собираться как `REST-only` base даже без premium adapter crates; +- premium protocol adapters подключаются через feature seams, а не как безусловная зависимость Community runtime. + ### 4.7. Protocol adapters - `crank-adapter-rest` @@ -129,6 +134,12 @@ Каждый adapter знает только свой протокол. +Для open-core split это означает: + +- `crank-adapter-rest` остается обязательной частью Community runtime; +- `crank-adapter-graphql`, `crank-adapter-grpc`, `crank-adapter-websocket`, `crank-adapter-soap` + должны подключаться как отделяемые protocol modules. + ### 4.8. `apps/admin-api` Должен содержать сервисные группы: diff --git a/docs/repository-split-map.md b/docs/repository-split-map.md index 4bf647b..eba1cc3 100644 --- a/docs/repository-split-map.md +++ b/docs/repository-split-map.md @@ -50,6 +50,11 @@ Public repository. - public CI/CD for Community; - Community docs, smoke docs и demo flow. +Техническая оговорка: + +- `crank-runtime` в Community должен уметь собираться без premium adapter crates; +- `UnsupportedProtocol` и capability model должны оставаться достаточным fallback для disabled protocol families. + В нем не должно остаться: - private token issuer implementations; @@ -106,6 +111,11 @@ Private repository. - все capability checks - `REST`-only product surface +Как промежуточный этап до физического переноса: + +- `crank-runtime` в общем коде должен иметь feature-gated seams для `GraphQL/gRPC/SOAP/WebSocket`; +- это позволяет потом перенести premium adapters в private repos без полного форка runtime base. + Уходит в `crank-enterprise`: - `crank-adapter-graphql` @@ -195,6 +205,13 @@ Private repository. 3. Зафиксировать отдельные package/release names. 4. Подготовить начальные README и baseline workflows для всех трех репозиториев. +Текущий статус: + +- `crank-community` уже создан; +- до старта physical split остаются внешние шаги: + - создать `crank-enterprise` + - создать `crank-cloud` + Пока эти четыре пункта не выполнены, physical split не начинается. Техническая заготовка для этого шага уже живет в: @@ -207,7 +224,8 @@ Private repository. Следующий управленческий шаг после завершения текущего boundary-трека: -- создать `crank-community` +- создать `crank-enterprise` +- создать `crank-cloud` - создать `crank-enterprise` - создать `crank-cloud`