feat: harden community production foundation through story 1.5
This commit is contained in:
+280
-71
@@ -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<dyn std::error::Error>> {
|
||||
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::<crank_config::ConfigError>() {
|
||||
return config.to_json();
|
||||
}
|
||||
if let Some(migration) = cause.downcast_ref::<crank_registry::MigrationError>() {
|
||||
return serde_json::json!({
|
||||
"status": "error",
|
||||
"code": migration.code(),
|
||||
"stage": migration.stage(),
|
||||
"version": migration.version(),
|
||||
"recovery": migration.recovery(),
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
if let Some(crank_registry::RegistryError::Migration(migration)) =
|
||||
cause.downcast_ref::<crank_registry::RegistryError>()
|
||||
{
|
||||
return serde_json::json!({
|
||||
"status": "error",
|
||||
"code": migration.code(),
|
||||
"stage": migration.stage(),
|
||||
"version": migration.version(),
|
||||
"recovery": migration.recovery(),
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
current = cause.source();
|
||||
}
|
||||
serde_json::json!({
|
||||
"status": "error",
|
||||
"code": "startup_failed",
|
||||
"stage": "startup",
|
||||
"version": null,
|
||||
"recovery": "contact_operator",
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn main_result() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let effective = parse_process(ProcessKind::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<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: McpProcessConfig,
|
||||
observability: &ObservabilityLifecycle,
|
||||
startup_completed: &mut bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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::<u64>().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<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
|
||||
return Ok(database_url.parse::<PgConnectOptions>()?);
|
||||
fn init_observability(
|
||||
config: &ObservabilitySettings,
|
||||
) -> Result<ObservabilityLifecycle, Box<dyn std::error::Error>> {
|
||||
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, crank_registry::PostgresPoolConfigError> {
|
||||
PostgresPoolConfig::try_new(
|
||||
config.pool.max_connections,
|
||||
config.pool.min_connections,
|
||||
config.pool.acquire_timeout_ms,
|
||||
config.pool.idle_timeout_ms,
|
||||
config.pool.max_lifetime_ms,
|
||||
)
|
||||
}
|
||||
|
||||
fn database_options(
|
||||
config: &DatabaseSettings,
|
||||
) -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Some(url) = &config.url {
|
||||
return url
|
||||
.expose_secret()
|
||||
.parse::<PgConnectOptions>()
|
||||
.map_err(|_| io::Error::other("database URL is invalid").into());
|
||||
}
|
||||
|
||||
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
|
||||
let port = env::var("POSTGRES_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(5432);
|
||||
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
|
||||
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
|
||||
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
|
||||
|
||||
Ok(PgConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.database(&database)
|
||||
.username(&username)
|
||||
.password(&password))
|
||||
.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<RequestRateLimitConfig, Box<dyn std::error::Error>>
|
||||
{
|
||||
let requests_per_second = env::var("CRANK_MCP_RATE_LIMIT_RPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(60);
|
||||
let burst = env::var("CRANK_MCP_RATE_LIMIT_BURST")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(120);
|
||||
|
||||
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||
fn runtime_cache_config(
|
||||
config: &McpProcessConfig,
|
||||
) -> Result<RuntimeCacheConfig, crank_runtime::RuntimeCacheConfigError> {
|
||||
RuntimeCacheConfig::try_new(
|
||||
match config.runtime.cache.backend {
|
||||
ConfigCacheBackend::Memory => CacheBackend::Memory,
|
||||
ConfigCacheBackend::Valkey => CacheBackend::Valkey,
|
||||
ConfigCacheBackend::Redis => CacheBackend::Redis,
|
||||
},
|
||||
config
|
||||
.runtime
|
||||
.cache
|
||||
.url
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user