feat: harden community production foundation through story 1.5
This commit is contained in:
@@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user