feat: harden community production foundation through story 1.5
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "crank-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
@@ -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(¤t, 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()))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'.'))
|
||||
}
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_config::{
|
||||
ConfigSource, DiagnosticCode, FieldMode, ProcessKind, ProcessScope, field_registry,
|
||||
parse_migrator, parse_process,
|
||||
};
|
||||
|
||||
fn required_admin() -> BTreeMap<String, String> {
|
||||
[
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn required_mcp() -> BTreeMap<String, String> {
|
||||
[("CRANK_MASTER_KEY", "master")]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrator_projection_requires_only_database_configuration() {
|
||||
let config = parse_migrator(ConfigSource::from_utf8(BTreeMap::new()))
|
||||
.expect("database defaults are sufficient for the controlled migration job");
|
||||
assert_eq!(config.database.host, "postgres");
|
||||
assert_eq!(config.database.port, 5432);
|
||||
assert_eq!(config.fingerprint().len(), 64);
|
||||
let debug = format!("{config:?}");
|
||||
assert!(
|
||||
!debug.contains("crank"),
|
||||
"database password must remain redacted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrator_ignores_service_configuration_and_rejects_database_typos() {
|
||||
let mut values = BTreeMap::from([
|
||||
("CRANK_MASTER_KEY".to_owned(), "secret-canary".to_owned()),
|
||||
(
|
||||
"CRANK_SESSION_SECRET".to_owned(),
|
||||
"secret-canary".to_owned(),
|
||||
),
|
||||
("CRANK_MCP_REFRESH_MS".to_owned(), "invalid".to_owned()),
|
||||
]);
|
||||
parse_migrator(ConfigSource::from_utf8(values.clone()))
|
||||
.expect("service fields are outside the database-only projection");
|
||||
values.insert("POSTGRES_PORRT".to_owned(), "5432".to_owned());
|
||||
let error = parse_migrator(ConfigSource::from_utf8(values)).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == DiagnosticCode::UnknownField)
|
||||
);
|
||||
assert!(!error.to_string().contains("secret-canary"));
|
||||
}
|
||||
|
||||
fn source_for(
|
||||
field: &crank_config::FieldSpec,
|
||||
value: String,
|
||||
) -> (ProcessKind, BTreeMap<String, String>) {
|
||||
let kind = match field.process {
|
||||
ProcessScope::McpServer => ProcessKind::McpServer,
|
||||
ProcessScope::Shared | ProcessScope::AdminApi => ProcessKind::AdminApi,
|
||||
};
|
||||
let mut vars = match kind {
|
||||
ProcessKind::AdminApi => required_admin(),
|
||||
ProcessKind::McpServer => required_mcp(),
|
||||
};
|
||||
vars.insert(field.env_name.to_owned(), value);
|
||||
match field.env_name {
|
||||
"POSTGRES_MAX_CONNECTIONS" => {
|
||||
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "0".into());
|
||||
}
|
||||
"POSTGRES_MIN_CONNECTIONS" => {
|
||||
vars.insert("POSTGRES_MAX_CONNECTIONS".into(), "1024".into());
|
||||
}
|
||||
"CRANK_ADMIN_RATE_LIMIT_RPS" => {
|
||||
vars.insert("CRANK_ADMIN_RATE_LIMIT_BURST".into(), "1000000".into());
|
||||
}
|
||||
"CRANK_ADMIN_RATE_LIMIT_BURST" => {
|
||||
vars.insert("CRANK_ADMIN_RATE_LIMIT_RPS".into(), "1".into());
|
||||
}
|
||||
"CRANK_MCP_RATE_LIMIT_RPS" => {
|
||||
vars.insert("CRANK_MCP_RATE_LIMIT_BURST".into(), "1000000".into());
|
||||
}
|
||||
"CRANK_MCP_RATE_LIMIT_BURST" => {
|
||||
vars.insert("CRANK_MCP_RATE_LIMIT_RPS".into(), "1".into());
|
||||
}
|
||||
"OTEL_BSP_MAX_QUEUE_SIZE" => {
|
||||
vars.insert("OTEL_BSP_MAX_EXPORT_BATCH_SIZE".into(), "1".into());
|
||||
}
|
||||
"OTEL_BSP_MAX_EXPORT_BATCH_SIZE" => {
|
||||
vars.insert("OTEL_BSP_MAX_QUEUE_SIZE".into(), "65536".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(kind, vars)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_covers_exactly_the_57_observed_runtime_names() {
|
||||
let registry = field_registry();
|
||||
assert_eq!(registry.len(), 57);
|
||||
let unique = registry
|
||||
.iter()
|
||||
.map(|field| field.env_name)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(unique.len(), registry.len());
|
||||
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW"));
|
||||
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS"));
|
||||
for field in registry {
|
||||
assert!(!field.semantic_path.is_empty());
|
||||
assert!(!field.env_name.is_empty());
|
||||
assert!(!field.value_type.is_empty());
|
||||
if let (Some(minimum), Some(maximum)) = (field.minimum, field.maximum) {
|
||||
assert!(minimum <= maximum, "{}", field.env_name);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
registry
|
||||
.iter()
|
||||
.find(|field| field.env_name == "CRANK_CACHE_DEFAULT_TTL_MS")
|
||||
.unwrap()
|
||||
.mode,
|
||||
FieldMode::DeprecatedNoEffect
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_preserved_and_invalid_values_never_fall_back() {
|
||||
let valid = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
ConfigSource::from_utf8(required_admin()),
|
||||
)
|
||||
.expect("minimal admin config");
|
||||
let admin = valid.admin().expect("admin projection");
|
||||
assert_eq!(admin.database.port, 5432);
|
||||
assert_eq!(admin.session_ttl_hours, 24);
|
||||
assert_eq!(admin.rate_limit.requests_per_second, 30);
|
||||
|
||||
for (name, value) in [
|
||||
("POSTGRES_PORT", "bad"),
|
||||
("CRANK_SESSION_TTL_HOURS", "bad"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "tru"),
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.to_owned(), value.to_owned());
|
||||
let error =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
let path = field_registry()
|
||||
.iter()
|
||||
.find(|field| field.env_name == name)
|
||||
.unwrap()
|
||||
.semantic_path;
|
||||
assert!(error.diagnostics().iter().any(|item| item.field == path));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_forms_conflict_and_owned_typos_fail_closed() {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(
|
||||
"CRANK_DATABASE_URL".into(),
|
||||
"postgres://user:secret@db/crank".into(),
|
||||
);
|
||||
vars.insert("POSTGRES_HOST".into(), "db".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.code == DiagnosticCode::Conflict)
|
||||
);
|
||||
|
||||
let mut vars = required_admin();
|
||||
vars.insert("CRANK_SESION_SECRET".into(), "canary".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.code == DiagnosticCode::UnknownField)
|
||||
);
|
||||
assert!(!error.to_string().contains("canary"));
|
||||
|
||||
for ghost in [
|
||||
"CRANK_RUNTIME_MAX_CONCURRENT_WINDOW",
|
||||
"CRANK_RUNTIME_MAX_CONCURRENT_JOBS",
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(ghost.into(), "4".into());
|
||||
let error =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(error.diagnostics().iter().any(|item| {
|
||||
item.code == DiagnosticCode::UnknownField && item.field == "environment.unknown"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_deployment_only_names_are_known_but_never_runtime_fields() {
|
||||
let mut vars = required_admin();
|
||||
for name in crank_config::deployment_field_registry() {
|
||||
vars.insert((*name).to_owned(), "deployment-value".to_owned());
|
||||
}
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
assert!(config.admin().is_some());
|
||||
assert!(
|
||||
field_registry()
|
||||
.iter()
|
||||
.all(|field| { !crank_config::deployment_field_registry().contains(&field.env_name) })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_but_unused_cache_ttl_is_an_explicit_non_pass_contract() {
|
||||
let mut vars = required_admin();
|
||||
vars.insert("CRANK_CACHE_DEFAULT_TTL_MS".into(), "5000".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(error.diagnostics().iter().any(|item| {
|
||||
item.code == DiagnosticCode::DeprecatedNoEffect && item.field == "cache.default_ttl_ms"
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_source_rejects_non_utf8_without_echoing_bytes() {
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
|
||||
let error = ConfigSource::from_os_iter([(
|
||||
OsString::from("CRANK_MASTER_KEY"),
|
||||
OsString::from_vec(vec![0xff, b'S', b'E', b'C', b'R', b'E', b'T']),
|
||||
)])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.code == DiagnosticCode::InvalidEncoding)
|
||||
);
|
||||
assert!(!error.to_string().contains("SECRET"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_source_ignores_unrelated_invalid_or_unbounded_values() {
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
|
||||
ConfigSource::from_os_iter([
|
||||
(
|
||||
OsString::from_vec(vec![0xff]),
|
||||
OsString::from_vec(vec![0xff]),
|
||||
),
|
||||
(
|
||||
OsString::from("JAVA_TOOL_OPTIONS"),
|
||||
OsString::from("x".repeat(20_000)),
|
||||
),
|
||||
(OsString::from("LANG"), OsString::from_vec(vec![0xff, b'x'])),
|
||||
])
|
||||
.expect("unrelated OS state is outside the runtime contract");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_specific_fields_and_zero_ports_fail_closed() {
|
||||
let mut mcp = required_mcp();
|
||||
mcp.insert("CRANK_SESSION_SECRET".into(), "wrong-process".into());
|
||||
let error = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp)).unwrap_err();
|
||||
assert!(error.diagnostics().iter().any(|item| {
|
||||
item.code == DiagnosticCode::UnknownField && item.field == "admin.session.secret"
|
||||
}));
|
||||
|
||||
for (kind, name, required) in [
|
||||
(ProcessKind::AdminApi, "CRANK_ADMIN_BIND", required_admin()),
|
||||
(
|
||||
ProcessKind::AdminApi,
|
||||
"CRANK_ADMIN_METRICS_BIND",
|
||||
required_admin(),
|
||||
),
|
||||
(ProcessKind::McpServer, "CRANK_MCP_BIND", required_mcp()),
|
||||
(
|
||||
ProcessKind::McpServer,
|
||||
"CRANK_MCP_METRICS_BIND",
|
||||
required_mcp(),
|
||||
),
|
||||
] {
|
||||
let mut vars = required;
|
||||
vars.insert(name.into(), "127.0.0.1:0".into());
|
||||
let error = parse_process(kind, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
let path = field_registry()
|
||||
.iter()
|
||||
.find(|field| field.env_name == name)
|
||||
.unwrap()
|
||||
.semantic_path;
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| { item.code == DiagnosticCode::OutOfRange && item.field == path })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
|
||||
for name in [
|
||||
"CRANK_MASTER_KEY",
|
||||
"CRANK_SESSION_SECRET",
|
||||
"CRANK_PASSWORD_PEPPER",
|
||||
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.into(), " ".into());
|
||||
assert!(
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
for (name, value) in [
|
||||
("CRANK_OUTBOUND_ALLOWED_HOSTS", "example.test:443"),
|
||||
("CRANK_DATABASE_URL", "postgres://db/crank?sslmode=bogus"),
|
||||
("CRANK_ENVIRONMENT", "bad environment"),
|
||||
("CRANK_SENTRY_DSN", "not-a-dsn"),
|
||||
("OTEL_EXPORTER_OTLP_HEADERS", "bad name=value"),
|
||||
(
|
||||
"CRANK_BASE_URL",
|
||||
"https://user:secret@example.test/path?token=x",
|
||||
),
|
||||
] {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.into(), value.into());
|
||||
let error =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.code == DiagnosticCode::InvalidType),
|
||||
"{name}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_field_and_typed_boundaries_fail_closed() {
|
||||
let cases = [
|
||||
("POSTGRES_MAX_CONNECTIONS", "1025"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
|
||||
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
|
||||
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
|
||||
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
|
||||
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
|
||||
("CRANK_BASE_URL", "file:///tmp/config"),
|
||||
];
|
||||
for (name, value) in cases {
|
||||
let mut vars = required_admin();
|
||||
vars.insert(name.to_owned(), value.to_owned());
|
||||
let error =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
|
||||
assert!(
|
||||
error.diagnostics().iter().any(|item| item.field
|
||||
== field_registry()
|
||||
.iter()
|
||||
.find(|field| field.env_name == name)
|
||||
.unwrap()
|
||||
.semantic_path),
|
||||
"{name}: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut vars = required_admin();
|
||||
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "21".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.code == DiagnosticCode::UnsafeCombination)
|
||||
);
|
||||
|
||||
let mut vars = required_admin();
|
||||
vars.insert("CRANK_CACHE_BACKEND".into(), "valkey".into());
|
||||
vars.insert("CRANK_CACHE_URL".into(), "https://not-a-cache.test".into());
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.field == "cache.url")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
||||
let mut vars = required_admin();
|
||||
vars.extend([
|
||||
("POSTGRES_PORT".into(), "65535".into()),
|
||||
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
|
||||
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
|
||||
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
|
||||
(
|
||||
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
|
||||
"67108864".into(),
|
||||
),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()),
|
||||
("CRANK_DEMO_SEED".into(), "off".into()),
|
||||
]);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
let admin = config.admin().unwrap();
|
||||
assert_eq!(admin.database.port, 65_535);
|
||||
assert_eq!(admin.database.pool.max_connections, 1024);
|
||||
assert_eq!(admin.database.pool.min_connections, 0);
|
||||
assert_eq!(admin.runtime.max_concurrent_unary, 1);
|
||||
assert!(admin.trust_forwarded_headers);
|
||||
assert!(!admin.demo_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_and_mcp_share_one_normalized_foundation() {
|
||||
let mut vars = required_admin();
|
||||
vars.extend([
|
||||
("CRANK_BASE_URL".into(), "https://crank.example.test".into()),
|
||||
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "72".into()),
|
||||
(
|
||||
"CRANK_OUTBOUND_ALLOWED_HOSTS".into(),
|
||||
"api.example.test".into(),
|
||||
),
|
||||
("POSTGRES_MAX_CONNECTIONS".into(), "24".into()),
|
||||
]);
|
||||
let admin =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars.clone())).unwrap();
|
||||
let mut mcp_vars = vars;
|
||||
for key in [
|
||||
"CRANK_SESSION_SECRET",
|
||||
"CRANK_PASSWORD_PEPPER",
|
||||
"CRANK_BOOTSTRAP_ADMIN_EMAIL",
|
||||
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
|
||||
] {
|
||||
mcp_vars.remove(key);
|
||||
}
|
||||
let mcp = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp_vars)).unwrap();
|
||||
let admin = admin.admin().unwrap();
|
||||
let mcp = mcp.mcp().unwrap();
|
||||
assert_eq!(admin.database.host, mcp.database.host);
|
||||
assert_eq!(
|
||||
admin.database.pool.max_connections,
|
||||
mcp.database.pool.max_connections
|
||||
);
|
||||
assert_eq!(admin.runtime.base_url, mcp.runtime.base_url);
|
||||
assert_eq!(
|
||||
admin.runtime.max_concurrent_unary,
|
||||
mcp.runtime.max_concurrent_unary
|
||||
);
|
||||
assert_eq!(
|
||||
admin.runtime.outbound.allowed_hosts,
|
||||
mcp.runtime.outbound.allowed_hosts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bounded_numeric_field_accepts_edges_and_rejects_outside_values() {
|
||||
for field in field_registry().iter().filter(|field| {
|
||||
field.mode == FieldMode::Effective && field.minimum.is_some() && field.maximum.is_some()
|
||||
}) {
|
||||
let minimum = field.minimum.unwrap();
|
||||
let maximum = field.maximum.unwrap();
|
||||
for accepted in [minimum, maximum] {
|
||||
let (kind, vars) = source_for(field, accepted.to_string());
|
||||
parse_process(kind, ConfigSource::from_utf8(vars))
|
||||
.unwrap_or_else(|error| panic!("{}={accepted}: {error}", field.env_name));
|
||||
}
|
||||
for rejected in [
|
||||
if minimum == 0 {
|
||||
"-1".to_owned()
|
||||
} else {
|
||||
(minimum - 1).to_string()
|
||||
},
|
||||
(maximum + 1).to_string(),
|
||||
] {
|
||||
let (kind, vars) = source_for(field, rejected);
|
||||
let error =
|
||||
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.field == field.semantic_path),
|
||||
"{}: {error}",
|
||||
field.env_name
|
||||
);
|
||||
}
|
||||
|
||||
for malformed in ["-1", "184467440737095516160", "1.5", " 1", "1\n"] {
|
||||
let (kind, vars) = source_for(field, malformed.to_owned());
|
||||
let error =
|
||||
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|item| item.field == field.semantic_path),
|
||||
"{}={malformed:?}: {error}",
|
||||
field.env_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_values_and_otel_precedence_remain_explicit() {
|
||||
let mut vars = required_admin();
|
||||
vars.extend([
|
||||
("CRANK_CACHE_BACKEND".into(), "redis".into()),
|
||||
(
|
||||
"CRANK_CACHE_URL".into(),
|
||||
"redis://cache.example.test:6379".into(),
|
||||
),
|
||||
(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT".into(),
|
||||
"https://generic.example.test".into(),
|
||||
),
|
||||
(
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT".into(),
|
||||
"https://traces.example.test".into(),
|
||||
),
|
||||
("OTEL_EXPORTER_OTLP_TIMEOUT".into(), "10000".into()),
|
||||
("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT".into(), "5000".into()),
|
||||
]);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
let admin = config.admin().unwrap();
|
||||
assert_eq!(
|
||||
admin.runtime.cache.backend,
|
||||
crank_config::CacheBackend::Redis
|
||||
);
|
||||
assert_eq!(
|
||||
admin.observability.otlp.endpoint.as_deref(),
|
||||
Some("https://generic.example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
admin.observability.otlp.traces_endpoint.as_deref(),
|
||||
Some("https://traces.example.test")
|
||||
);
|
||||
assert_eq!(admin.observability.otlp.timeout.as_deref(), Some("10000"));
|
||||
assert_eq!(
|
||||
admin.observability.otlp.traces_timeout.as_deref(),
|
||||
Some("5000")
|
||||
);
|
||||
assert_eq!(config.deprecations().len(), 1);
|
||||
assert_eq!(config.deprecations()[0].field, "cache.backend");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crank_config::{field_registry, render};
|
||||
|
||||
#[test]
|
||||
fn generated_contract_is_deterministic_complete_and_redacted() {
|
||||
assert_eq!(render::schema_json(), render::schema_json());
|
||||
let schema: serde_json::Value = serde_json::from_str(&render::schema_json()).unwrap();
|
||||
assert_eq!(
|
||||
schema["fields"].as_array().unwrap().len(),
|
||||
field_registry().len()
|
||||
);
|
||||
for section in [render::env_section(false), render::env_section(true)] {
|
||||
assert!(!section.contains("change-me"));
|
||||
assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW"));
|
||||
assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS"));
|
||||
assert!(!section.contains("CRANK_CACHE_DEFAULT_TTL_MS"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_reference_distinguishes_required_and_optional_fields() {
|
||||
let reference = render::reference_section();
|
||||
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |"
|
||||
));
|
||||
assert!(
|
||||
reference
|
||||
.contains("| `CRANK_DATABASE_URL` | `database.url` | `Shared` | `url/-` | `blank` |")
|
||||
);
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_LOG_LEVEL` | `observability.log_filter` | `Shared` | `string/-` | `blank` |"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_replacement_is_bounded_to_the_generated_region() {
|
||||
let input = "before\n# BEGIN GENERATED CRANK RUNTIME CONFIG\nstale\n# END GENERATED CRANK RUNTIME CONFIG\nafter\n";
|
||||
let output = render::replace_marked(
|
||||
input,
|
||||
render::BEGIN_MARKER,
|
||||
render::END_MARKER,
|
||||
&render::env_section(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(output.starts_with("before\n"));
|
||||
assert!(output.ends_with("\nafter\n"));
|
||||
assert!(!output.contains("stale"));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_config::{ConfigSource, ProcessKind, parse_process};
|
||||
|
||||
fn config(secret: &str) -> crank_config::EffectiveConfig {
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", secret),
|
||||
("CRANK_SESSION_SECRET", secret),
|
||||
("CRANK_PASSWORD_PEPPER", secret),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", secret),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_absent_from_debug_display_and_fingerprint() {
|
||||
let first = config("CANARY_ONE");
|
||||
let second = config("CANARY_TWO");
|
||||
let rendered = format!("{first:?}");
|
||||
assert!(!rendered.contains("CANARY_ONE"));
|
||||
assert_eq!(first.fingerprint(), second.fingerprint());
|
||||
assert_eq!(first.fingerprint().len(), 64);
|
||||
assert!(
|
||||
first
|
||||
.fingerprint()
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_semantics_not_input_spelling_drive_fingerprint() {
|
||||
let mut canonical = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
("CRANK_TRUST_FORWARDED_HEADERS", "true"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut compatibility = canonical.clone();
|
||||
compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into());
|
||||
|
||||
let canonical_config = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
ConfigSource::from_utf8(canonical.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let compatibility_config = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
ConfigSource::from_utf8(compatibility),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
canonical_config.fingerprint(),
|
||||
compatibility_config.fingerprint()
|
||||
);
|
||||
assert_eq!(compatibility_config.deprecations().len(), 1);
|
||||
|
||||
canonical.insert("CRANK_SESSION_TTL_HOURS".into(), "48".into());
|
||||
let changed = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(canonical)).unwrap();
|
||||
assert_ne!(changed.fingerprint(), compatibility_config.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
|
||||
let canary = "CANARY_SECRET_VALUE";
|
||||
let vars = [
|
||||
("CRANK_MASTER_KEY", canary),
|
||||
("CRANK_SESSION_SECRET", canary),
|
||||
("CRANK_PASSWORD_PEPPER", canary),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", canary),
|
||||
(
|
||||
"CRANK_DATABASE_URL",
|
||||
"postgres://owner:CANARY_SECRET_VALUE@db/crank",
|
||||
),
|
||||
("POSTGRES_PASSWORD", canary),
|
||||
("CRANK_CACHE_BACKEND", "memory"),
|
||||
("CRANK_CACHE_URL", "redis://:CANARY_SECRET_VALUE@cache:6379"),
|
||||
(
|
||||
"CRANK_SENTRY_DSN",
|
||||
"https://CANARY_SECRET_VALUE@sentry.test/1",
|
||||
),
|
||||
("CRANK_METRICS_BEARER_TOKEN", canary),
|
||||
(
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"authorization=CANARY_SECRET_VALUE",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
||||
let display = error.to_string();
|
||||
let json = error.to_json();
|
||||
assert!(json.len() <= 65_536);
|
||||
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
|
||||
assert!(!display.contains(canary));
|
||||
assert!(!json.contains(canary));
|
||||
assert!(error.diagnostics().len() <= 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
|
||||
let mut vars = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
("CRANK_STORAGE_ROOT", "/CANARY/private/storage"),
|
||||
("POSTGRES_HOST", "CANARY-db.internal"),
|
||||
("CRANK_OUTBOUND_ALLOWED_HOSTS", "CANARY-api.internal"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
vars.insert(
|
||||
"CRANK_BASE_URL".into(),
|
||||
"https://CANARY.example.test".into(),
|
||||
);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
||||
let rendered = format!("{:?}", config.admin().unwrap());
|
||||
assert!(!rendered.contains("CANARY"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_database_and_admin_default_urls_drive_fingerprint() {
|
||||
let base = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let implicit =
|
||||
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(base.clone())).unwrap();
|
||||
let mut explicit = base.clone();
|
||||
explicit.insert("CRANK_BASE_URL".into(), "http://localhost:3000".into());
|
||||
let explicit = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(explicit)).unwrap();
|
||||
assert_eq!(implicit.fingerprint(), explicit.fingerprint());
|
||||
|
||||
let mut url = base;
|
||||
url.insert(
|
||||
"CRANK_DATABASE_URL".into(),
|
||||
"postgres://crank:rotated@postgres/crank".into(),
|
||||
);
|
||||
let url = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(url)).unwrap();
|
||||
assert_eq!(implicit.fingerprint(), url.fingerprint());
|
||||
|
||||
let tls = [
|
||||
("CRANK_MASTER_KEY", "master"),
|
||||
("CRANK_SESSION_SECRET", "session"),
|
||||
("CRANK_PASSWORD_PEPPER", "pepper"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
||||
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
||||
(
|
||||
"CRANK_DATABASE_URL",
|
||||
"postgres://crank:rotated@postgres/crank?sslmode=require",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let tls = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(tls)).unwrap();
|
||||
assert_ne!(implicit.fingerprint(), tls.fingerprint());
|
||||
}
|
||||
Reference in New Issue
Block a user