Files
crank/crates/crank-config/src/process.rs
T

997 lines
35 KiB
Rust

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},
};
use std::{
collections::BTreeMap,
fmt,
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use url::Url;
mod external_references;
mod list_parsers;
pub use external_references::ExternalReferenceSettings;
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<SecretString>,
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<SecretString>,
}
#[derive(Clone)]
pub struct OutboundSettings {
pub allowed_hosts: Vec<String>,
pub denied_hosts: Vec<String>,
pub max_request_bytes: usize,
pub max_response_bytes: usize,
}
#[derive(Clone)]
pub struct RuntimeSettings {
pub master_key: SecretString,
pub base_url: Option<String>,
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<SecretString>,
}
#[derive(Clone, Default)]
pub struct OtlpSettings {
pub endpoint: Option<String>,
pub traces_endpoint: Option<String>,
pub protocol: Option<String>,
pub traces_protocol: Option<String>,
pub timeout: Option<String>,
pub traces_timeout: Option<String>,
pub headers: Option<SecretString>,
pub traces_headers: Option<SecretString>,
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<SecretString>,
pub metrics: MetricsSettings,
pub otlp: OtlpSettings,
}
#[derive(Clone)]
pub struct AdminProcessConfig {
pub database: DatabaseSettings,
pub runtime: RuntimeSettings,
pub external_references: ExternalReferenceSettings,
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 trusted_proxy_ips: Vec<IpAddr>,
pub bootstrap_email: String,
pub bootstrap_password: Option<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(Box<AdminProcessConfig>),
Mcp(Box<McpProcessConfig>),
}
#[derive(Clone)]
pub struct EffectiveConfig {
kind: ProcessKind,
fingerprint: String,
projection: Projection,
deprecations: Vec<DeprecationRecord>,
}
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<String, String>,
diagnostics: Vec<Diagnostic>,
deprecations: Vec<DeprecationRecord>,
}
impl<'a> Parser<'a> {
fn new(kind: ProcessKind, values: &'a BTreeMap<String, String>) -> 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::<std::collections::BTreeSet<_>>();
let deployment_only = deployment_field_registry()
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>();
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<String>) {
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<String> {
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 {
let value = self.string(name, default);
if let Some(spec) = field_registry().iter().find(|field| field.env_name == name)
&& let Some(minimum) = spec.minimum
&& !value.is_empty()
&& value.len() < minimum as usize
{
self.push(DiagnosticCode::OutOfRange, spec.semantic_path);
}
SecretString::new(value)
}
fn optional_secret(&mut self, name: &'static str) -> Option<SecretString> {
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::<u64>() {
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::<SocketAddr>() {
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<String> {
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<u64>) -> Option<String> {
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::<u64>() {
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<SecretString> {
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 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<DeprecationRecord>), 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<EffectiveConfig, ConfigError> {
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_request_bytes: parser.number("CRANK_OUTBOUND_MAX_REQUEST_BYTES") as usize,
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;
let bind_addr = parser.socket("CRANK_ADMIN_BIND");
if burst < rps {
parser.push(DiagnosticCode::UnsafeCombination, "admin.rate_limit.burst");
}
if observability.environment == "production"
&& !bind_addr.ip().is_loopback()
&& !runtime
.base_url
.as_deref()
.is_some_and(|url| url.starts_with("https://"))
{
parser.push(DiagnosticCode::UnsafeCombination, "admin.exposure.tls");
}
Projection::Admin(Box::new(AdminProcessConfig {
database,
runtime,
external_references: external_references::parse(&mut parser),
observability,
bind_addr,
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,
trusted_proxy_ips: parser.ip_list("CRANK_TRUSTED_PROXY_IPS"),
bootstrap_email: parser.string("CRANK_BOOTSTRAP_ADMIN_EMAIL", None),
bootstrap_password: parser.optional_secret("CRANK_BOOTSTRAP_ADMIN_PASSWORD"),
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(Box::new(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<String> {
let (database, runtime, observability, process_parts): (
&DatabaseSettings,
&RuntimeSettings,
&ObservabilitySettings,
Vec<String>,
) = 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_proxies={:?}", config.trusted_proxy_ips),
format!("demo={}", config.demo_seed),
format!(
"external_reference_prefixes={}",
config.external_references.allowed_url_prefixes.join(",")
),
format!(
"external_reference_limits={}:{}:{}:{}:{}",
config.external_references.max_depth,
config.external_references.max_documents,
config.external_references.max_fetch_bytes,
config.external_references.fetch_timeout_ms,
config.external_references.max_expanded_nodes,
),
"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
.as_ref()
.is_some_and(SecretString::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_request={}", runtime.outbound.max_request_bytes),
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
}