feat: harden community production foundation through story 1.5

This commit is contained in:
2026-08-14 00:21:59 +03:00
parent c30461cc92
commit f6fc2e5c9b
161 changed files with 16758 additions and 2515 deletions
@@ -0,0 +1,73 @@
use std::{env, fs, path::Path};
use crank_config::render::{
BEGIN_MARKER, DOC_BEGIN_MARKER, DOC_END_MARKER, END_MARKER, env_section, reference_section,
replace_marked, schema_json,
};
fn main() {
if let Err(error) = run() {
eprintln!("config contract check failed: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let mode = env::args().nth(1).unwrap_or_else(|| "--check".to_owned());
if !matches!(mode.as_str(), "--check" | "--write") {
return Err("expected --check or --write".to_owned());
}
let write = mode == "--write";
sync_file(
Path::new("docs/schemas/runtime-config.schema.json"),
schema_json(),
write,
)?;
for (path, production) in [
(".env.example", false),
("deploy/community/.env.example", true),
("deploy/community/.env.images.example", true),
] {
sync_marked(
Path::new(path),
BEGIN_MARKER,
END_MARKER,
&env_section(production),
write,
)?;
}
sync_marked(
Path::new("docs/runtime-config.md"),
DOC_BEGIN_MARKER,
DOC_END_MARKER,
&reference_section(),
write,
)?;
Ok(())
}
fn sync_marked(
path: &Path,
begin: &str,
end: &str,
replacement: &str,
write: bool,
) -> Result<(), String> {
let current =
fs::read_to_string(path).map_err(|_| format!("cannot read {}", path.display()))?;
let expected = replace_marked(&current, begin, end, replacement)
.ok_or_else(|| format!("missing generated markers in {}", path.display()))?;
sync_file(path, expected, write)
}
fn sync_file(path: &Path, expected: String, write: bool) -> Result<(), String> {
let current = fs::read_to_string(path).unwrap_or_default();
if current == expected {
return Ok(());
}
if write {
fs::write(path, expected).map_err(|_| format!("cannot write {}", path.display()))
} else {
Err(format!("{} is out of date", path.display()))
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::fmt;
use crate::{
AdminProcessConfig, CacheSettings, DatabaseSettings, McpProcessConfig, MetricsSettings,
MigratorConfig, ObservabilitySettings, OtlpSettings, OutboundSettings, RuntimeSettings,
};
impl fmt::Debug for DatabaseSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseSettings")
.field("url", &self.url.as_ref().map(|_| "configured"))
.field("password", &self.password)
.field("pool", &self.pool)
.finish_non_exhaustive()
}
}
impl fmt::Debug for CacheSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CacheSettings")
.field("backend", &self.backend)
.field("url", &self.url.as_ref().map(|_| "configured"))
.finish()
}
}
impl fmt::Debug for OutboundSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OutboundSettings")
.field("allowed_host_count", &self.allowed_hosts.len())
.field("denied_host_count", &self.denied_hosts.len())
.field("max_response_bytes", &self.max_response_bytes)
.finish()
}
}
impl fmt::Debug for RuntimeSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimeSettings")
.field("master_key", &self.master_key)
.field("base_url", &self.base_url.as_ref().map(|_| "configured"))
.field("max_concurrent_unary", &self.max_concurrent_unary)
.field("max_concurrent_sessions", &self.max_concurrent_sessions)
.field("cache", &self.cache)
.field("outbound", &self.outbound)
.finish()
}
}
impl fmt::Debug for MetricsSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MetricsSettings")
.field("enabled", &self.enabled)
.field("loopback", &self.bind_addr.ip().is_loopback())
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| "configured"),
)
.finish()
}
}
impl fmt::Debug for OtlpSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OtlpSettings")
.field("endpoint", &self.endpoint.as_ref().map(|_| "configured"))
.field(
"traces_endpoint",
&self.traces_endpoint.as_ref().map(|_| "configured"),
)
.field("headers", &self.headers.as_ref().map(|_| "configured"))
.field(
"traces_headers",
&self.traces_headers.as_ref().map(|_| "configured"),
)
.field("max_queue_size", &self.max_queue_size)
.field("max_export_batch_size", &self.max_export_batch_size)
.finish_non_exhaustive()
}
}
impl fmt::Debug for ObservabilitySettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ObservabilitySettings")
.field("environment", &"configured")
.field("log_filter", &"configured")
.field(
"sentry_dsn",
&self.sentry_dsn.as_ref().map(|_| "configured"),
)
.field("metrics", &self.metrics)
.field("otlp", &self.otlp)
.finish()
}
}
impl fmt::Debug for AdminProcessConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdminProcessConfig")
.field("database", &self.database)
.field("runtime", &self.runtime)
.field("observability", &self.observability)
.field("storage_root", &"configured")
.field("session_secret", &self.session_secret)
.field("password_pepper", &self.password_pepper)
.field("bootstrap_password", &self.bootstrap_password)
.finish_non_exhaustive()
}
}
impl fmt::Debug for McpProcessConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("McpProcessConfig")
.field("database", &self.database)
.field("runtime", &self.runtime)
.field("observability", &self.observability)
.finish_non_exhaustive()
}
}
impl fmt::Debug for MigratorConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MigratorConfig")
.field("database", &self.database)
.field("fingerprint", &self.fingerprint())
.finish()
}
}
+206
View File
@@ -0,0 +1,206 @@
use std::fmt;
use serde::{Serialize, Serializer};
const MAX_DIAGNOSTICS: usize = 100;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum DiagnosticCode {
MissingRequired,
InvalidEncoding,
InvalidType,
OutOfRange,
UnknownField,
Conflict,
UnsafeCombination,
DeprecatedNoEffect,
}
impl Serialize for DiagnosticCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl DiagnosticCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::MissingRequired => "config.missing_required",
Self::InvalidEncoding => "config.invalid_encoding",
Self::InvalidType => "config.invalid_type",
Self::OutOfRange => "config.out_of_range",
Self::UnknownField => "config.unknown_field",
Self::Conflict => "config.conflict",
Self::UnsafeCombination => "config.unsafe_combination",
Self::DeprecatedNoEffect => "config.deprecated_no_effect",
}
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct Diagnostic {
pub code: DiagnosticCode,
pub field: String,
pub message_ru: &'static str,
pub message_en: &'static str,
}
impl Diagnostic {
pub(crate) fn new(code: DiagnosticCode, field: impl Into<String>) -> Self {
let (message_ru, message_en) = match code {
DiagnosticCode::MissingRequired => (
"Обязательный параметр не настроен.",
"A required configuration field is not configured.",
),
DiagnosticCode::InvalidEncoding => (
"Параметр должен быть корректной строкой UTF-8.",
"The configuration field must be valid UTF-8.",
),
DiagnosticCode::InvalidType => (
"Параметр имеет недопустимый тип или формат.",
"The configuration field has an invalid type or format.",
),
DiagnosticCode::OutOfRange => (
"Параметр находится вне допустимых границ.",
"The configuration field is outside its allowed bounds.",
),
DiagnosticCode::UnknownField => (
"Неизвестный параметр в управляемом пространстве имён.",
"Unknown field in an owned configuration namespace.",
),
DiagnosticCode::Conflict => (
"Одновременно заданы конфликтующие источники конфигурации.",
"Conflicting configuration sources are set at the same time.",
),
DiagnosticCode::UnsafeCombination => (
"Комбинация параметров небезопасна или противоречива.",
"The configuration combination is unsafe or inconsistent.",
),
DiagnosticCode::DeprecatedNoEffect => (
"Устаревший параметр не имеет поддерживаемого эффекта.",
"The deprecated field has no supported effect.",
),
};
let mut field = field.into();
if field.len() > 256 {
let mut boundary = 256;
while !field.is_char_boundary(boundary) {
boundary -= 1;
}
field.truncate(boundary);
}
Self {
code,
field,
message_ru,
message_en,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigError {
diagnostics: Vec<Diagnostic>,
omitted: usize,
}
impl ConfigError {
pub fn single(code: DiagnosticCode, field: impl Into<String>) -> Self {
Self::from_diagnostics(vec![Diagnostic::new(code, field)])
}
pub(crate) fn from_diagnostics(mut diagnostics: Vec<Diagnostic>) -> Self {
diagnostics.sort();
diagnostics.dedup();
let mut omitted = diagnostics.len().saturating_sub(MAX_DIAGNOSTICS);
diagnostics.truncate(MAX_DIAGNOSTICS);
while serialized_len(&diagnostics, omitted) > 65_536 && !diagnostics.is_empty() {
diagnostics.pop();
omitted += 1;
}
Self {
diagnostics,
omitted,
}
}
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub fn omitted(&self) -> usize {
self.omitted
}
pub fn to_json(&self) -> String {
#[derive(Serialize)]
struct Report<'a> {
diagnostics: &'a [Diagnostic],
omitted: usize,
}
serde_json::to_string(&Report {
diagnostics: &self.diagnostics,
omitted: self.omitted,
})
.unwrap_or_else(|_| "{\"diagnostics\":[],\"omitted\":0}".to_owned())
}
}
fn serialized_len(diagnostics: &[Diagnostic], omitted: usize) -> usize {
#[derive(Serialize)]
struct Report<'a> {
diagnostics: &'a [Diagnostic],
omitted: usize,
}
serde_json::to_vec(&Report {
diagnostics,
omitted,
})
.map_or(usize::MAX, |value| value.len())
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, diagnostic) in self.diagnostics.iter().enumerate() {
if index > 0 {
formatter.write_str("; ")?;
}
write!(
formatter,
"{} field={} ru={} en={}",
diagnostic.code.as_str(),
diagnostic.field,
diagnostic.message_ru,
diagnostic.message_en
)?;
}
if self.omitted > 0 {
write!(formatter, "; diagnostics_omitted={}", self.omitted)?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unicode_fields_and_worst_case_json_remain_bounded() {
let field = format!("{}{}", "\\\"".repeat(120), "💣".repeat(100));
let diagnostics = (0..200)
.map(|index| Diagnostic::new(DiagnosticCode::InvalidType, format!("{index}:{field}")))
.collect();
let error = ConfigError::from_diagnostics(diagnostics);
let json = error.to_json();
assert!(json.len() <= 65_536);
assert!(error.omitted() > 0);
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
assert!(json.contains("config.invalid_type"));
}
}
+11
View File
@@ -0,0 +1,11 @@
use sha2::{Digest, Sha256};
pub(crate) fn sha256_hex(parts: impl IntoIterator<Item = String>) -> String {
let mut hasher = Sha256::new();
hasher.update(b"crank-config-fingerprint-v1\0");
for part in parts {
hasher.update(part.len().to_le_bytes());
hasher.update(part.as_bytes());
}
format!("{:x}", hasher.finalize())
}
+25
View File
@@ -0,0 +1,25 @@
//! Typed bootstrap configuration contract for Crank processes.
mod debug;
mod diagnostic;
mod fingerprint;
mod migrator;
mod process;
pub mod render;
mod schema;
mod source;
mod validation;
mod value;
pub use diagnostic::{ConfigError, Diagnostic, DiagnosticCode};
pub use migrator::{MigratorConfig, parse_migrator};
pub use process::{
AdminProcessConfig, CacheBackend, CacheSettings, DatabaseSettings, DeprecationRecord,
EffectiveConfig, McpProcessConfig, MetricsSettings, ObservabilitySettings, OtlpSettings,
OutboundSettings, PoolSettings, ProcessKind, RateLimitSettings, RuntimeSettings, parse_process,
};
pub use schema::{
FieldMode, FieldSpec, ProcessScope, Sensitivity, deployment_field_registry, field_registry,
};
pub use source::ConfigSource;
pub use value::SecretString;
+47
View File
@@ -0,0 +1,47 @@
use crate::{
ConfigError, ConfigSource, DatabaseSettings, DeprecationRecord, fingerprint::sha256_hex,
process::parse_database_source,
};
#[derive(Clone)]
pub struct MigratorConfig {
pub database: DatabaseSettings,
fingerprint: String,
deprecations: Vec<DeprecationRecord>,
}
impl MigratorConfig {
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn deprecations(&self) -> &[DeprecationRecord] {
&self.deprecations
}
}
pub fn parse_migrator(source: ConfigSource) -> Result<MigratorConfig, ConfigError> {
let (database, deprecations) = parse_database_source(source.retain_for_migrator())?;
let fingerprint = sha256_hex([
"schema=crank-config-migrator-v1".to_owned(),
format!("url={}", database.url.is_some()),
format!("host={}", database.host.to_ascii_lowercase()),
format!("port={}", database.port),
format!("database={}", database.database),
format!("username={}", database.username),
format!("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
),
]);
Ok(MigratorConfig {
database,
fingerprint,
deprecations,
})
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
use serde::Serialize;
use crate::{FieldMode, FieldSpec, Sensitivity, deployment_field_registry, field_registry};
pub const BEGIN_MARKER: &str = "# BEGIN GENERATED CRANK RUNTIME CONFIG";
pub const END_MARKER: &str = "# END GENERATED CRANK RUNTIME CONFIG";
pub const DOC_BEGIN_MARKER: &str = "<!-- BEGIN GENERATED CRANK RUNTIME CONFIG -->";
pub const DOC_END_MARKER: &str = "<!-- END GENERATED CRANK RUNTIME CONFIG -->";
#[derive(Serialize)]
struct RuntimeContract<'a> {
schema_version: u32,
generated_by: &'static str,
fields: &'a [FieldSpec],
deployment_only_fields: &'static [&'static str],
}
pub fn schema_json() -> String {
let contract = RuntimeContract {
schema_version: 1,
generated_by: "crank-config",
fields: field_registry(),
deployment_only_fields: deployment_field_registry(),
};
let mut rendered = serde_json::to_string_pretty(&contract).expect("static contract serializes");
rendered.push('\n');
rendered
}
pub fn env_section(production: bool) -> String {
let mut output = String::new();
output.push_str(BEGIN_MARKER);
output.push('\n');
for field in field_registry()
.iter()
.filter(|field| field.mode == FieldMode::Effective)
{
let value = example_value(field, production);
output.push_str(field.env_name);
output.push('=');
output.push_str(&value);
output.push('\n');
}
output.push_str(END_MARKER);
output.push('\n');
output
}
pub fn reference_section() -> String {
let mut output = String::new();
output.push_str(DOC_BEGIN_MARKER);
output.push_str("\n\n| Environment | Semantic path | Process | Type/unit | Default | Bounds | Sensitivity | Mode |\n");
output.push_str("|---|---|---|---|---|---|---|---|\n");
for field in field_registry() {
let unit = field.unit.unwrap_or("-");
let default = match (field.sensitivity, field.default, field.required) {
(Sensitivity::Secret, Some(_), _) => "configured",
(Sensitivity::Secret, None, true) => "required/blank",
(Sensitivity::Secret, None, false) => "blank",
(_, Some(default), _) => default,
(_, None, true) => "required/blank",
(_, None, false) => "blank",
};
let bounds = match (field.minimum, field.maximum) {
(Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"),
_ => "-".to_owned(),
};
output.push_str(&format!(
"| `{}` | `{}` | `{:?}` | `{}/{}` | `{}` | `{}` | `{:?}` | `{:?}` |\n",
field.env_name,
field.semantic_path,
field.process,
field.value_type,
unit,
default,
bounds,
field.sensitivity,
field.mode,
));
}
output.push('\n');
output.push_str(DOC_END_MARKER);
output.push('\n');
output
}
pub fn replace_marked(content: &str, begin: &str, end: &str, replacement: &str) -> Option<String> {
let start = content.find(begin)?;
let tail = &content[start..];
let end_offset = tail.find(end)? + end.len();
let suffix_start = start + end_offset;
let mut rendered = String::with_capacity(content.len() + replacement.len());
rendered.push_str(&content[..start]);
rendered.push_str(replacement.trim_end());
rendered.push_str(&content[suffix_start..]);
Some(rendered)
}
fn example_value(field: &FieldSpec, production: bool) -> String {
if field.sensitivity == Sensitivity::Secret {
return String::new();
}
match (field.env_name, production) {
("CRANK_ENVIRONMENT", true) => "production".to_owned(),
("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(),
("POSTGRES_HOST", true) => "postgres".to_owned(),
("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(),
_ => field.default.unwrap_or("").to_owned(),
}
}
+797
View File
@@ -0,0 +1,797 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessScope {
Shared,
AdminApi,
McpServer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Sensitivity {
Public,
Internal,
Secret,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldMode {
Effective,
DeprecatedNoEffect,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct FieldSpec {
pub semantic_path: &'static str,
pub env_name: &'static str,
pub process: ProcessScope,
pub value_type: &'static str,
pub unit: Option<&'static str>,
pub default: Option<&'static str>,
pub required: bool,
pub minimum: Option<u64>,
pub maximum: Option<u64>,
pub sensitivity: Sensitivity,
pub mode: FieldMode,
pub compatibility: Option<&'static str>,
pub rules: &'static [&'static str],
}
impl FieldSpec {
pub fn default_for(self, process: ProcessScope) -> Option<&'static str> {
match (self.env_name, process) {
("CRANK_BASE_URL", ProcessScope::AdminApi) => Some("http://localhost:3000"),
("CRANK_LOG_LEVEL", ProcessScope::AdminApi) => Some("admin_api=info,tower_http=info"),
("CRANK_LOG_LEVEL", ProcessScope::McpServer) => Some("mcp_server=info,tower_http=info"),
_ => self.default,
}
}
}
pub(crate) fn semantic_path_for(env_name: &str) -> &str {
field_registry()
.iter()
.find(|field| field.env_name == env_name)
.map_or(env_name, |field| field.semantic_path)
}
macro_rules! f {
($path:literal,$name:literal,$proc:ident,$type:literal,$unit:expr,$default:expr,$min:expr,$max:expr,$sensitivity:ident) => {
FieldSpec {
semantic_path: $path,
env_name: $name,
process: ProcessScope::$proc,
value_type: $type,
unit: $unit,
default: $default,
required: false,
minimum: $min,
maximum: $max,
sensitivity: Sensitivity::$sensitivity,
mode: FieldMode::Effective,
compatibility: None,
rules: &[],
}
};
}
static FIELDS: [FieldSpec; 57] = [
FieldSpec {
compatibility: Some("legacy URL form"),
rules: &[
"takes precedence over generated default-valued POSTGRES_HOST/PORT/DB/USER/PASSWORD",
"conflicts with any non-default decomposed database value",
],
..f!(
"database.url",
"CRANK_DATABASE_URL",
Shared,
"url",
None,
None,
None,
None,
Secret
)
},
f!(
"database.host",
"POSTGRES_HOST",
Shared,
"string",
None,
Some("postgres"),
None,
None,
Internal
),
f!(
"database.port",
"POSTGRES_PORT",
Shared,
"u16",
Some("port"),
Some("5432"),
Some(1),
Some(65535),
Public
),
f!(
"database.name",
"POSTGRES_DB",
Shared,
"string",
None,
Some("crank"),
None,
None,
Internal
),
f!(
"database.user",
"POSTGRES_USER",
Shared,
"string",
None,
Some("crank"),
None,
None,
Internal
),
f!(
"database.password",
"POSTGRES_PASSWORD",
Shared,
"secret",
None,
Some("configured"),
None,
None,
Secret
),
FieldSpec {
rules: &["must be >= min_connections"],
..f!(
"database.pool.max_connections",
"POSTGRES_MAX_CONNECTIONS",
Shared,
"u32",
Some("connections"),
Some("20"),
Some(1),
Some(1024),
Public
)
},
FieldSpec {
rules: &["must be <= max_connections"],
..f!(
"database.pool.min_connections",
"POSTGRES_MIN_CONNECTIONS",
Shared,
"u32",
Some("connections"),
Some("2"),
Some(0),
Some(1024),
Public
)
},
f!(
"database.pool.acquire_timeout_ms",
"POSTGRES_ACQUIRE_TIMEOUT_MS",
Shared,
"u64",
Some("milliseconds"),
Some("5000"),
Some(1),
Some(300000),
Public
),
f!(
"database.pool.idle_timeout_ms",
"POSTGRES_IDLE_TIMEOUT_MS",
Shared,
"u64",
Some("milliseconds"),
Some("600000"),
Some(1000),
Some(86400000),
Public
),
f!(
"database.pool.max_lifetime_ms",
"POSTGRES_MAX_LIFETIME_MS",
Shared,
"u64",
Some("milliseconds"),
Some("1800000"),
Some(1000),
Some(86400000),
Public
),
FieldSpec {
required: true,
..f!(
"runtime.master_key",
"CRANK_MASTER_KEY",
Shared,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"runtime.base_url",
"CRANK_BASE_URL",
Shared,
"url",
None,
None,
None,
None,
Internal
),
f!(
"runtime.max_concurrent_unary",
"CRANK_RUNTIME_MAX_CONCURRENT_UNARY",
Shared,
"u32",
Some("requests"),
Some("64"),
Some(1),
Some(65535),
Public
),
FieldSpec {
compatibility: Some("redis value is deprecated in favor of valkey"),
rules: &["external backend requires cache.url"],
..f!(
"cache.backend",
"CRANK_CACHE_BACKEND",
Shared,
"enum",
None,
Some("memory"),
None,
None,
Public
)
},
FieldSpec {
rules: &["forbidden with memory backend"],
..f!(
"cache.url",
"CRANK_CACHE_URL",
Shared,
"url",
None,
None,
None,
None,
Secret
)
},
FieldSpec {
mode: FieldMode::DeprecatedNoEffect,
..f!(
"cache.default_ttl_ms",
"CRANK_CACHE_DEFAULT_TTL_MS",
Shared,
"u64",
Some("milliseconds"),
None,
Some(1),
Some(86400000),
Public
)
},
f!(
"outbound.allowed_hosts",
"CRANK_OUTBOUND_ALLOWED_HOSTS",
Shared,
"host_list",
None,
Some(""),
None,
None,
Internal
),
FieldSpec {
rules: &["deny entries override allow entries"],
..f!(
"outbound.denied_hosts",
"CRANK_OUTBOUND_DENIED_HOSTS",
Shared,
"host_list",
None,
Some(""),
None,
None,
Internal
)
},
f!(
"outbound.max_response_bytes",
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES",
Shared,
"u64",
Some("bytes"),
Some("4194304"),
Some(1),
Some(67108864),
Public
),
f!(
"observability.environment",
"CRANK_ENVIRONMENT",
Shared,
"label",
None,
Some("development"),
None,
None,
Public
),
f!(
"observability.log_filter",
"CRANK_LOG_LEVEL",
Shared,
"string",
None,
None,
None,
None,
Public
),
f!(
"observability.sentry_dsn",
"CRANK_SENTRY_DSN",
Shared,
"url",
None,
None,
None,
None,
Secret
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"observability.metrics.enabled",
"CRANK_METRICS_ENABLED",
Shared,
"bool",
None,
Some("true"),
None,
None,
Public
)
},
FieldSpec {
rules: &["required when an enabled metrics bind is non-loopback"],
..f!(
"observability.metrics.bearer_token",
"CRANK_METRICS_BEARER_TOKEN",
Shared,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"observability.otlp.endpoint",
"OTEL_EXPORTER_OTLP_ENDPOINT",
Shared,
"url",
None,
None,
None,
None,
Internal
),
FieldSpec {
rules: &["overrides generic OTLP endpoint"],
..f!(
"observability.otlp.traces_endpoint",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
Shared,
"url",
None,
None,
None,
None,
Internal
)
},
f!(
"observability.otlp.protocol",
"OTEL_EXPORTER_OTLP_PROTOCOL",
Shared,
"enum",
None,
Some("http/protobuf"),
None,
None,
Public
),
f!(
"observability.otlp.traces_protocol",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
Shared,
"enum",
None,
None,
None,
None,
Public
),
f!(
"observability.otlp.timeout",
"OTEL_EXPORTER_OTLP_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
Some("10000"),
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.traces_timeout",
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
None,
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.headers",
"OTEL_EXPORTER_OTLP_HEADERS",
Shared,
"headers",
None,
None,
None,
None,
Secret
),
f!(
"observability.otlp.traces_headers",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
Shared,
"headers",
None,
None,
None,
None,
Secret
),
f!(
"observability.otlp.max_queue_size",
"OTEL_BSP_MAX_QUEUE_SIZE",
Shared,
"u32",
Some("spans"),
Some("2048"),
Some(1),
Some(65536),
Public
),
FieldSpec {
rules: &["must be <= max_queue_size"],
..f!(
"observability.otlp.max_export_batch_size",
"OTEL_BSP_MAX_EXPORT_BATCH_SIZE",
Shared,
"u32",
Some("spans"),
Some("512"),
Some(1),
Some(65536),
Public
)
},
f!(
"observability.otlp.schedule_delay",
"OTEL_BSP_SCHEDULE_DELAY",
Shared,
"duration",
Some("milliseconds"),
Some("5000"),
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.export_timeout",
"OTEL_BSP_EXPORT_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
Some("30000"),
Some(1),
Some(300000),
Public
),
f!(
"admin.bind",
"CRANK_ADMIN_BIND",
AdminApi,
"socket",
None,
Some("0.0.0.0:3001"),
None,
None,
Internal
),
f!(
"admin.metrics_bind",
"CRANK_ADMIN_METRICS_BIND",
AdminApi,
"socket",
None,
Some("127.0.0.1:9464"),
None,
None,
Internal
),
f!(
"admin.storage_root",
"CRANK_STORAGE_ROOT",
AdminApi,
"absolute_path",
None,
Some("/var/lib/crank/storage"),
None,
None,
Internal
),
f!(
"admin.rate_limit.rps",
"CRANK_ADMIN_RATE_LIMIT_RPS",
AdminApi,
"u32",
Some("requests_per_second"),
Some("30"),
Some(1),
Some(100000),
Public
),
FieldSpec {
rules: &["must be >= admin rate RPS"],
..f!(
"admin.rate_limit.burst",
"CRANK_ADMIN_RATE_LIMIT_BURST",
AdminApi,
"u32",
Some("requests"),
Some("60"),
Some(1),
Some(1000000),
Public
)
},
f!(
"admin.invocation_log_retention_days",
"CRANK_INVOCATION_LOG_RETENTION_DAYS",
AdminApi,
"u32",
Some("days"),
Some("30"),
Some(1),
Some(36500),
Public
),
FieldSpec {
required: true,
..f!(
"admin.session.secret",
"CRANK_SESSION_SECRET",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
FieldSpec {
required: true,
..f!(
"admin.password_pepper",
"CRANK_PASSWORD_PEPPER",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"admin.session.ttl_hours",
"CRANK_SESSION_TTL_HOURS",
AdminApi,
"u32",
Some("hours"),
Some("24"),
Some(1),
Some(8760),
Public
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"admin.trust_forwarded_headers",
"CRANK_TRUST_FORWARDED_HEADERS",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
Public
)
},
FieldSpec {
required: true,
..f!(
"admin.bootstrap.email",
"CRANK_BOOTSTRAP_ADMIN_EMAIL",
AdminApi,
"string",
None,
None,
None,
None,
Internal
)
},
FieldSpec {
required: true,
..f!(
"admin.bootstrap.password",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"admin.bootstrap.display_name",
"CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME",
AdminApi,
"string",
None,
Some("Crank Owner"),
None,
None,
Internal
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"admin.demo_seed",
"CRANK_DEMO_SEED",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
Public
)
},
f!(
"mcp.bind",
"CRANK_MCP_BIND",
McpServer,
"socket",
None,
Some("0.0.0.0:3002"),
None,
None,
Internal
),
f!(
"mcp.metrics_bind",
"CRANK_MCP_METRICS_BIND",
McpServer,
"socket",
None,
Some("127.0.0.1:9465"),
None,
None,
Internal
),
f!(
"mcp.refresh_ms",
"CRANK_MCP_REFRESH_MS",
McpServer,
"u64",
Some("milliseconds"),
Some("5000"),
Some(100),
Some(3600000),
Public
),
f!(
"mcp.rate_limit.rps",
"CRANK_MCP_RATE_LIMIT_RPS",
McpServer,
"u32",
Some("requests_per_second"),
Some("60"),
Some(1),
Some(100000),
Public
),
FieldSpec {
rules: &["must be >= MCP rate RPS"],
..f!(
"mcp.rate_limit.burst",
"CRANK_MCP_RATE_LIMIT_BURST",
McpServer,
"u32",
Some("requests"),
Some("120"),
Some(1),
Some(1000000),
Public
)
},
f!(
"runtime.max_concurrent_sessions",
"CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS",
McpServer,
"u32",
Some("sessions"),
Some("16"),
Some(1),
Some(65535),
Public
),
];
pub fn field_registry() -> &'static [FieldSpec] {
&FIELDS
}
static DEPLOYMENT_FIELDS: [&str; 12] = [
"COMPOSE_PROJECT_NAME",
"POSTGRES_PUBLISH_BIND",
"POSTGRES_PUBLISH_PORT",
"CRANK_ADMIN_API_IMAGE",
"CRANK_MCP_SERVER_IMAGE",
"CRANK_UI_IMAGE",
"CRANK_PUBLISH_BIND",
"CRANK_ADMIN_PUBLISH_PORT",
"CRANK_MCP_PUBLISH_PORT",
"CRANK_UI_PUBLISH_PORT",
"VALKEY_PUBLISH_BIND",
"VALKEY_PUBLISH_PORT",
];
pub fn deployment_field_registry() -> &'static [&'static str] {
&DEPLOYMENT_FIELDS
}
+85
View File
@@ -0,0 +1,85 @@
use std::{collections::BTreeMap, ffi::OsString};
use crate::{ConfigError, Diagnostic, DiagnosticCode, deployment_field_registry, field_registry};
#[derive(Clone, Debug, Default)]
pub struct ConfigSource {
values: BTreeMap<String, String>,
}
impl ConfigSource {
pub fn from_utf8(values: BTreeMap<String, String>) -> Self {
Self { values }
}
pub fn from_os() -> Result<Self, ConfigError> {
Self::from_os_iter(std::env::vars_os())
}
pub fn from_os_for_migrator() -> Result<Self, ConfigError> {
Self::from_os_iter_filtered(std::env::vars_os(), |name| {
name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_")
})
}
pub fn from_os_iter<I>(values: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (OsString, OsString)>,
{
Self::from_os_iter_filtered(values, |_| true)
}
fn from_os_iter_filtered<I, F>(values: I, include: F) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (OsString, OsString)>,
F: Fn(&str) -> bool,
{
let mut parsed = BTreeMap::new();
let mut diagnostics = Vec::new();
for (name, value) in values {
let Ok(name) = name.into_string() else {
// Owned names are ASCII. A non-UTF-8 name therefore cannot belong
// to Crank and must not make startup depend on unrelated OS state.
continue;
};
if !include(&name) {
continue;
}
let owned = name.starts_with("CRANK_")
|| name.starts_with("POSTGRES_")
|| name.starts_with("OTEL_");
let known = field_registry().iter().any(|field| field.env_name == name)
|| deployment_field_registry().contains(&name.as_str());
if !owned && !known {
continue;
}
let value = match value.into_string() {
Ok(value) => value,
Err(_) => {
let field = field_registry()
.iter()
.find(|field| field.env_name == name)
.map_or("environment.unknown", |field| field.semantic_path);
diagnostics.push(Diagnostic::new(DiagnosticCode::InvalidEncoding, field));
continue;
}
};
parsed.insert(name, value);
}
if diagnostics.is_empty() {
Ok(Self { values: parsed })
} else {
Err(ConfigError::from_diagnostics(diagnostics))
}
}
pub(crate) fn values(&self) -> &BTreeMap<String, String> {
&self.values
}
pub(crate) fn retain_for_migrator(mut self) -> Self {
self.values
.retain(|name, _| name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_"));
self
}
}
+42
View File
@@ -0,0 +1,42 @@
pub(crate) fn valid_percent_encoding(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
if index + 2 >= bytes.len()
|| !bytes[index + 1].is_ascii_hexdigit()
|| !bytes[index + 2].is_ascii_hexdigit()
{
return false;
}
index += 3;
} else {
index += 1;
}
}
true
}
pub(crate) fn valid_database_host(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 253
&& !value.chars().any(char::is_whitespace)
&& (value.parse::<std::net::IpAddr>().is_ok()
|| value.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& !label.starts_with('-')
&& !label.ends_with('-')
}))
}
pub(crate) fn valid_database_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
+42
View File
@@ -0,0 +1,42 @@
use std::fmt;
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);
impl SecretString {
pub(crate) fn new(value: String) -> Self {
Self(value)
}
/// Deliberate composition boundary. Never use in diagnostics or fingerprints.
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn is_configured(&self) -> bool {
!self.0.is_empty()
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SecretString")
.field(&if self.is_configured() {
"configured"
} else {
"unconfigured"
})
.finish()
}
}
impl fmt::Display for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(if self.is_configured() {
"configured"
} else {
"unconfigured"
})
}
}