997 lines
35 KiB
Rust
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
|
|
}
|