use std::{
collections::BTreeMap,
env,
error::Error,
ffi::OsString,
fmt, fs, io,
path::{Path, PathBuf},
time::Duration,
};
use cortexkit_log::Retention;
use serde::Deserialize;
use subc_control::ModuleProtocol;
use subc_jsonc::jsonc_to_json;
use subc_protocol::manifest::is_valid_capability_identifier;
use crate::{
supervise::{ModuleOverlap, SUBC_SPAWN_ROLE_ENV},
HealthAction, HealthConfig, ModuleSpec, RestartPolicy,
};
const DAEMON_CONFIG_RELATIVE_PATH: &str = "cortexkit/subc.jsonc";
const SUPPORTED_CONFIG_VERSION: u32 = 1;
pub(crate) const CK_LOG_ENV: &str = "CK_LOG";
pub(crate) const CAPTURE_MAX_FILE_MB_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_FILE_MB";
pub(crate) const CAPTURE_KEEP_ENV: &str = "__SUBC_CAPTURE_LOG_KEEP";
pub(crate) const CAPTURE_MAX_AGE_DAYS_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_AGE_DAYS";
pub(crate) const CHILD_LOG_MAX_AGE_DAYS_ENV: &str = "CK_LOG_MAX_AGE_DAYS";
pub(crate) const CHILD_LOG_ALARM_SEGMENT_MB_ENV: &str = "CK_LOG_ALARM_SEGMENT_MB";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartRequiredSection {
Port,
Storage,
AdmissionFactsCarrierModuleId,
AdmissionFactsTargets,
}
impl RestartRequiredSection {
pub const ALL: [Self; 4] = [
Self::Port,
Self::Storage,
Self::AdmissionFactsCarrierModuleId,
Self::AdmissionFactsTargets,
];
pub const fn label(self) -> &'static str {
match self {
Self::Port => "port",
Self::Storage => "storage",
Self::AdmissionFactsCarrierModuleId => "admission_facts_carrier_module_id",
Self::AdmissionFactsTargets => "admission_facts_targets",
}
}
}
const ROUTE_BIND_RELAY_ZERO_MESSAGE: &str = "route_bind_relay_timeout_ms must be greater than 0 (a zero budget fails every bind to the module; to make a module unreachable use enabled: false)";
const RESTART_WINDOW_ZERO_MESSAGE: &str = "restart.window_secs must be greater than 0 (a zero window holds no crash, so the budget can never be spent; for effectively unlimited restarts set a deliberately large window_secs, and to stop restarting entirely set restart.max_restarts: 0)";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoggingConfig {
pub level: String,
pub tags: BTreeMap<String, String>,
pub retention: Retention,
pub alarm_segment_mb: u32,
}
impl LoggingConfig {
pub fn filter_spec(&self, module_id: &str) -> String {
let mut directives = vec![self.level.clone()];
directives.extend(self.tags.iter().map(|(logger, level)| {
if logger == module_id || logger.contains('.') {
format!("{logger}={level}")
} else {
format!("{module_id}.{logger}={level}")
}
}));
directives.join(",")
}
pub fn segment_retention(&self) -> cortexkit_log::SegmentRetention {
cortexkit_log::SegmentRetention {
max_age_days: self.retention.max_age_days,
alarm_segment_mb: self.alarm_segment_mb,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DaemonConfig {
pub path: PathBuf,
pub port: Option<u16>,
pub drain_timeout_ms: Option<u64>,
pub route_bind_relay_timeout_ms: Option<u64>,
pub modules: Vec<ConfiguredModule>,
pub storage: Option<StorageConfig>,
pub admission_facts_carrier_module_id: Option<String>,
pub admission_facts_targets: Option<Vec<String>>,
pub reserved_capabilities: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageConfig {
Sqlite { data_home: PathBuf },
}
impl StorageConfig {
pub fn descriptor_for(&self, module_id: &str) -> serde_json::Value {
match self {
StorageConfig::Sqlite { data_home } => {
let data_home = data_home.to_string_lossy();
let path = format!(
"{}/cortexkit/{module_id}/store.db",
data_home.trim_end_matches('/')
);
serde_json::json!({
"module_id": module_id,
"storage_namespace": "default",
"isolation": { "kind": "module" },
"backend": { "backend": "sqlite", "path": path },
})
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredModule {
pub module_id: String,
pub program: PathBuf,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub log: Option<LoggingConfig>,
pub enabled: bool,
pub reserved: bool,
pub reserved_prefixes: Vec<String>,
pub protocol: ModuleProtocol,
pub overlap: ModuleOverlap,
pub health: HealthConfig,
pub drain_timeout_ms: Option<u64>,
pub route_bind_relay_timeout_ms: Option<u64>,
pub restart: RestartPolicy,
}
impl ConfiguredModule {
pub fn module_spec(&self) -> ModuleSpec {
let mut env = self.env.clone();
if let Some(log) = &self.log {
env.retain(|(key, _)| {
key != CK_LOG_ENV
&& key != CAPTURE_MAX_FILE_MB_ENV
&& key != CAPTURE_KEEP_ENV
&& key != CAPTURE_MAX_AGE_DAYS_ENV
});
env.retain(|(key, _)| {
key != CHILD_LOG_MAX_AGE_DAYS_ENV && key != CHILD_LOG_ALARM_SEGMENT_MB_ENV
});
env.push((CK_LOG_ENV.to_string(), log.filter_spec(&self.module_id)));
env.push((
CHILD_LOG_MAX_AGE_DAYS_ENV.to_string(),
log.retention.max_age_days.to_string(),
));
env.push((
CHILD_LOG_ALARM_SEGMENT_MB_ENV.to_string(),
log.alarm_segment_mb.to_string(),
));
env.push((
CAPTURE_MAX_FILE_MB_ENV.to_string(),
log.retention.max_file_mb.to_string(),
));
env.push((CAPTURE_KEEP_ENV.to_string(), log.retention.keep.to_string()));
env.push((
CAPTURE_MAX_AGE_DAYS_ENV.to_string(),
log.retention.max_age_days.to_string(),
));
}
ModuleSpec {
module_id: self.module_id.clone(),
program: self.program.clone(),
args: self.args.clone(),
env,
reserved: self.reserved,
reserved_prefixes: self.reserved_prefixes.clone(),
protocol: self.protocol,
overlap: self.overlap,
}
}
}
#[derive(Debug)]
pub enum DaemonConfigError {
Read {
path: PathBuf,
source: io::Error,
},
InvalidJsonc {
path: PathBuf,
message: String,
},
InvalidJson {
path: PathBuf,
source: serde_json::Error,
},
UnsupportedVersion {
path: PathBuf,
version: u32,
},
InvalidValue {
path: PathBuf,
message: String,
},
}
#[derive(Debug, Deserialize)]
struct RawDaemonConfig {
version: u32,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
drain_timeout_ms: Option<u64>,
#[serde(default)]
route_bind_relay_timeout_ms: Option<u64>,
#[serde(default)]
log: Option<RawLoggingConfig>,
#[serde(default)]
modules: BTreeMap<String, RawModuleConfig>,
#[serde(default)]
storage: Option<RawStorageConfig>,
#[serde(default)]
admission_facts_carrier_module_id: Option<String>,
#[serde(default)]
admission_facts_targets: Option<Vec<String>>,
#[serde(default)]
reserved_capabilities: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "backend", rename_all = "snake_case")]
enum RawStorageConfig {
Sqlite {
#[serde(default)]
data_home: Option<PathBuf>,
},
}
#[derive(Debug, Deserialize)]
struct RawModuleConfig {
program: PathBuf,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
log: Option<RawLoggingConfig>,
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default)]
reserved: bool,
#[serde(default)]
reserved_prefixes: Vec<String>,
#[serde(default)]
protocol: Option<String>,
#[serde(default)]
overlap: Option<String>,
#[serde(default)]
health: Option<RawHealthConfig>,
#[serde(default)]
drain_timeout_ms: Option<u64>,
#[serde(default)]
route_bind_relay_timeout_ms: Option<u64>,
#[serde(default)]
restart: Option<RawRestartConfig>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawLoggingConfig {
#[serde(default)]
level: Option<String>,
#[serde(default)]
tags: BTreeMap<String, String>,
#[serde(default)]
alarm_segment_mb: Option<u32>,
#[serde(default)]
max_file_mb: Option<u32>,
#[serde(default)]
keep: Option<u8>,
#[serde(default)]
max_age_days: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawRestartConfig {
#[serde(default)]
max_restarts: Option<u32>,
#[serde(default)]
window_secs: Option<u64>,
#[serde(default)]
backoff_ms: Option<u64>,
#[serde(default)]
max_backoff_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawHealthConfig {
#[serde(default)]
cadence_ms: Option<u64>,
#[serde(default)]
deadline_ms: Option<u64>,
#[serde(default)]
failure_threshold: Option<u32>,
#[serde(default)]
on_degraded: Option<RawHealthAction>,
#[serde(default)]
on_failing: Option<RawHealthAction>,
#[serde(default)]
critical: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum RawHealthAction {
Report,
Restart,
Alert,
}
pub fn default_config_path() -> PathBuf {
default_config_home().join(DAEMON_CONFIG_RELATIVE_PATH)
}
pub fn default_config_home() -> PathBuf {
if let Some(config_home) = non_empty_os_var("XDG_CONFIG_HOME") {
return PathBuf::from(config_home);
}
#[cfg(windows)]
{
if let Some(app_data) = non_empty_os_var("APPDATA") {
return PathBuf::from(app_data);
}
if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
return PathBuf::from(user_profile).join("AppData").join("Roaming");
}
}
if let Some(home) = non_empty_os_var("HOME") {
return PathBuf::from(home).join(".config");
}
PathBuf::from(".config")
}
pub fn load(path: impl AsRef<Path>) -> Result<Option<DaemonConfig>, DaemonConfigError> {
let path = path.as_ref();
let Some(doc) = read_config_doc(path)? else {
return Ok(None);
};
parse_doc(&doc, path).map(Some)
}
pub fn load_logging(path: impl AsRef<Path>) -> Result<Option<LoggingConfig>, DaemonConfigError> {
let path = path.as_ref();
let Some(doc) = read_config_doc(path)? else {
return Ok(None);
};
let json = jsonc_to_json(&doc).map_err(|message| DaemonConfigError::InvalidJsonc {
path: path.to_path_buf(),
message,
})?;
let raw: RawDaemonConfig =
serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
path: path.to_path_buf(),
source,
})?;
if raw.version != SUPPORTED_CONFIG_VERSION {
return Err(DaemonConfigError::UnsupportedVersion {
path: path.to_path_buf(),
version: raw.version,
});
}
raw.log
.map(|log| parse_logging_config(log, path, "daemon log"))
.transpose()
}
pub fn ensure_daemon_run_dir_private() -> Result<PathBuf, io::Error> {
let path = daemon_run_dir()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
ensure_directory_private(&path)?;
Ok(path)
}
#[cfg(unix)]
fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if !path.exists() {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)?;
return Ok(());
}
let mode = fs::metadata(path)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(not(unix))]
fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
if !path.exists() {
fs::create_dir_all(path)?;
}
Ok(())
}
pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
daemon_run_dir_from(default_data_home())
}
fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
if !data_home.is_absolute() {
return Err(DaemonRunDirError::RelativeDataHome { data_home });
}
Ok(data_home.join("cortexkit").join("run"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonRunDirError {
RelativeDataHome { data_home: PathBuf },
}
impl fmt::Display for DaemonRunDirError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RelativeDataHome { data_home } => write!(
f,
"cannot resolve the daemon run directory: the data home `{}` is relative, \
so it would land under the current working directory; {}",
data_home.display(),
DATA_HOME_REMEDY
),
}
}
}
impl std::error::Error for DaemonRunDirError {}
#[cfg(windows)]
const DATA_HOME_REMEDY: &str =
"set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
#[cfg(not(windows))]
const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
match fs::read_to_string(path) {
Ok(doc) => Ok(Some(doc)),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(DaemonConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
path: path.to_path_buf(),
message,
})?;
let raw: RawDaemonConfig =
serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
path: path.to_path_buf(),
source,
})?;
if raw.version != SUPPORTED_CONFIG_VERSION {
return Err(DaemonConfigError::UnsupportedVersion {
path: path.to_path_buf(),
version: raw.version,
});
}
let daemon_logging = raw
.log
.map(|log| parse_logging_config(log, path, "daemon log"))
.transpose()?;
let default_drain_timeout_ms = raw.drain_timeout_ms;
let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
Some(0) => {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
});
}
Some(value) => Some(value),
None => None,
};
let modules = raw
.modules
.into_iter()
.map(|(module_id, module)| {
let health = module
.health
.map(|health| parse_health_config(health, path, &module_id))
.transpose()?
.unwrap_or_default();
if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module id '{}' is not usable as a path component ({reason}): \
the daemon derives each module's store path from its id",
module_id.escape_debug()
),
});
}
let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
Some(0) => {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
module_id = module_id.escape_debug()
),
});
}
Some(value) => Some(value),
None => default_route_bind_relay_timeout_ms,
};
let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' sets {SUBC_SPAWN_ROLE_ENV} in env; that variable is set by the supervisor on a swap candidate only and cannot be configured",
module_id = module_id.escape_debug()
),
});
}
if protocol == ModuleProtocol::None && module.reserved {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' sets reserved: true with protocol: \"none\"; \
reserved is enforced on the module's HELLO and a protocol: \"none\" \
module never registers, so the reservation could never be checked",
module_id = module_id.escape_debug()
),
});
}
let restart = parse_restart_config(module.restart, path, &module_id)?;
let log = module
.log
.map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
.transpose()?
.or_else(|| daemon_logging.clone());
Ok(ConfiguredModule {
module_id,
program: module.program,
args: module.args,
env: module.env.into_iter().collect(),
log,
enabled: module.enabled,
reserved: module.reserved,
reserved_prefixes: module.reserved_prefixes,
protocol,
overlap,
health,
drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
restart,
})
})
.collect::<Result<Vec<_>, DaemonConfigError>>()?;
validate_reserved_prefixes(&modules, path)?;
validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
validate_admission_facts_config(
&modules,
raw.admission_facts_carrier_module_id.as_deref(),
raw.admission_facts_targets.as_deref(),
path,
)?;
let storage = raw
.storage
.map(|s| match s {
RawStorageConfig::Sqlite { data_home } => {
let data_home = data_home.unwrap_or_else(default_data_home);
if !data_home.is_absolute() {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"storage data home resolved to the relative path {} \
(no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
in the daemon's environment); refusing to serve a \
cwd-relative storage descriptor to modules. Set \
XDG_DATA_HOME or HOME to an absolute path, or set \
storage.data_home in this file.",
data_home.display()
),
});
}
Ok(StorageConfig::Sqlite { data_home })
}
})
.transpose()?;
Ok(DaemonConfig {
path: path.to_path_buf(),
port: raw.port,
drain_timeout_ms: default_drain_timeout_ms,
route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
modules,
storage,
admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
admission_facts_targets: raw.admission_facts_targets,
reserved_capabilities: raw.reserved_capabilities,
})
}
fn parse_logging_config(
raw: RawLoggingConfig,
path: &Path,
owner: &str,
) -> Result<LoggingConfig, DaemonConfigError> {
fn valid_level(level: &str) -> bool {
matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
}
let level = raw.level.unwrap_or_else(|| "info".to_string());
if !valid_level(&level) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
),
});
}
for (tag, tag_level) in &raw.tags {
let well_formed = !tag.is_empty()
&& tag.split('.').all(|segment| {
let mut chars = segment.chars();
matches!(chars.next(), Some('a'..='z'))
&& chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
});
if !well_formed {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
),
});
}
if !valid_level(tag_level) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
),
});
}
}
let defaults = Retention::default();
let retention = Retention {
max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
keep: raw.keep.unwrap_or(defaults.keep),
max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
};
if retention.max_file_mb == 0 {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!("{owner}.max_file_mb must be greater than 0"),
});
}
let alarm_segment_mb = raw
.alarm_segment_mb
.unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
if alarm_segment_mb == 0 {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!("{owner}.alarm_segment_mb must be greater than 0"),
});
}
Ok(LoggingConfig {
level,
tags: raw.tags,
retention,
alarm_segment_mb,
})
}
fn parse_module_protocol(
raw: Option<&str>,
path: &Path,
module_id: &str,
) -> Result<ModuleProtocol, DaemonConfigError> {
match raw {
None | Some("subc") => Ok(ModuleProtocol::Subc),
Some("none") => Ok(ModuleProtocol::None),
Some(other) => Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' declares protocol {other:?}; supported values are \
\"subc\" (the default when the key is absent) and \"none\"",
module_id = module_id.escape_debug(),
),
}),
}
}
fn parse_module_overlap(
raw: Option<&str>,
path: &Path,
module_id: &str,
) -> Result<ModuleOverlap, DaemonConfigError> {
match raw {
None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
Some("safe") => Ok(ModuleOverlap::Safe),
Some(other) => Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' declares overlap {other:?}; supported values are \
\"exclusive\" (the default when the key is absent) and \"safe\"",
module_id = module_id.escape_debug(),
),
}),
}
}
fn validate_reserved_capabilities(
bindings: &BTreeMap<String, String>,
path: &Path,
) -> Result<(), DaemonConfigError> {
for (capability, module_id) in bindings {
if !is_valid_capability_identifier(capability) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"reserved_capabilities key {:?} is not a valid capability identifier",
capability
),
});
}
if module_id.trim().is_empty() {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"reserved_capabilities binding for {:?} has an empty module id",
capability
),
});
}
if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
capability, module_id
),
});
}
}
Ok(())
}
fn validate_admission_facts_config(
modules: &[ConfiguredModule],
carrier_module_id: Option<&str>,
targets: Option<&[String]>,
path: &Path,
) -> Result<(), DaemonConfigError> {
let Some(carrier_module_id) = carrier_module_id else {
return Ok(());
};
let Some(carrier) = modules
.iter()
.find(|module| module.module_id == carrier_module_id)
else {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
),
});
};
if !carrier.enabled || !carrier.reserved {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
),
});
}
let Some(targets) = targets else {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
});
};
if targets.is_empty() || targets.iter().any(String::is_empty) {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message:
"admission_facts_targets must be non-empty and must not contain empty module ids"
.to_string(),
});
}
Ok(())
}
fn default_enabled() -> bool {
true
}
fn validate_reserved_prefixes(
modules: &[ConfiguredModule],
path: &Path,
) -> Result<(), DaemonConfigError> {
for module in modules {
if module.reserved_prefixes.is_empty() {
continue;
}
if !module.reserved {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
module.module_id
),
});
}
for prefix in &module.reserved_prefixes {
if !prefix.ends_with(':') {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{}' reserved prefix '{}' must end with ':'",
module.module_id, prefix
),
});
}
}
}
for module in modules {
for prefix in &module.reserved_prefixes {
if let Some(colliding) = modules
.iter()
.find(|candidate| candidate.module_id.starts_with(prefix))
{
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
prefix, module.module_id, colliding.module_id
),
});
}
}
}
for (left_index, left) in modules.iter().enumerate() {
for right in modules.iter().skip(left_index + 1) {
if left.module_id == right.module_id {
continue;
}
for left_prefix in &left.reserved_prefixes {
for right_prefix in &right.reserved_prefixes {
if left_prefix.starts_with(right_prefix)
|| right_prefix.starts_with(left_prefix)
{
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
left_prefix, left.module_id, right_prefix, right.module_id
),
});
}
}
}
}
}
Ok(())
}
fn parse_health_config(
raw: RawHealthConfig,
path: &Path,
module_id: &str,
) -> Result<HealthConfig, DaemonConfigError> {
let defaults = HealthConfig::default();
let cadence = positive_millis(
raw.cadence_ms,
defaults.cadence,
path,
module_id,
"cadence_ms",
)?;
let deadline = positive_millis(
raw.deadline_ms,
defaults.deadline,
path,
module_id,
"deadline_ms",
)?;
let failure_threshold = match raw.failure_threshold {
Some(0) => {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!("module '{module_id}' health.failure_threshold must be positive"),
})
}
Some(value) => value,
None => defaults.failure_threshold,
};
Ok(HealthConfig {
cadence,
deadline,
failure_threshold,
on_degraded: match raw.on_degraded {
Some(RawHealthAction::Restart) => {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' health.on_degraded may not be 'restart': a degraded module is slow-but-moving, so restarting it converts transient load into an outage. Use 'report' or 'alert' (Health-Path v2: only total wreckage or reported-unresponsiveness restarts)."
),
});
}
Some(action) => health_action(action),
None => defaults.on_degraded,
},
on_failing: raw
.on_failing
.map(health_action)
.unwrap_or(defaults.on_failing),
critical: raw.critical,
})
}
fn parse_restart_config(
raw: Option<RawRestartConfig>,
path: &Path,
module_id: &str,
) -> Result<RestartPolicy, DaemonConfigError> {
let defaults = RestartPolicy::default();
let Some(raw) = raw else {
return Ok(defaults);
};
let window = match raw.window_secs {
Some(0) => {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
module_id = module_id.escape_debug()
),
});
}
Some(secs) => Duration::from_secs(secs),
None => defaults.window,
};
let backoff = raw
.backoff_ms
.map(Duration::from_millis)
.unwrap_or(defaults.backoff);
let max_backoff = raw
.max_backoff_ms
.map(Duration::from_millis)
.unwrap_or(defaults.max_backoff);
if max_backoff < backoff {
return Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!(
"module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
module_id.escape_debug()
),
});
}
Ok(RestartPolicy {
max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
backoff,
max_backoff,
window,
})
}
fn positive_millis(
value: Option<u64>,
default: std::time::Duration,
path: &Path,
module_id: &str,
field: &str,
) -> Result<std::time::Duration, DaemonConfigError> {
match value {
Some(0) => Err(DaemonConfigError::InvalidValue {
path: path.to_path_buf(),
message: format!("module '{module_id}' health.{field} must be positive"),
}),
Some(value) => Ok(std::time::Duration::from_millis(value)),
None => Ok(default),
}
}
fn health_action(action: RawHealthAction) -> HealthAction {
match action {
RawHealthAction::Report => HealthAction::Report,
RawHealthAction::Restart => HealthAction::Restart,
RawHealthAction::Alert => HealthAction::Alert,
}
}
pub(crate) fn default_data_home() -> PathBuf {
if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
return PathBuf::from(data_home);
}
#[cfg(windows)]
{
if let Some(app_data) = non_empty_os_var("APPDATA") {
return PathBuf::from(app_data);
}
if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
return PathBuf::from(user_profile).join("AppData").join("Roaming");
}
}
if let Some(home) = non_empty_os_var("HOME") {
return PathBuf::from(home).join(".local").join("share");
}
PathBuf::from(".local").join("share")
}
fn non_empty_os_var(key: &str) -> Option<OsString> {
let value = env::var_os(key)?;
if value.is_empty() {
None
} else {
Some(value)
}
}
impl fmt::Display for DaemonConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, source } => {
write!(f, "failed to read daemon config {}: {source}", path.display())
}
Self::InvalidJsonc { path, message } => {
write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
}
Self::InvalidJson { path, source } => {
write!(f, "invalid daemon config {}: {source}", path.display())
}
Self::UnsupportedVersion { path, version } => write!(
f,
"invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
path.display()
),
Self::InvalidValue { path, message } => {
write!(f, "invalid daemon config {}: {message}", path.display())
}
}
}
}
impl Error for DaemonConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::InvalidJson { source, .. } => Some(source),
Self::InvalidJsonc { .. }
| Self::UnsupportedVersion { .. }
| Self::InvalidValue { .. } => None,
}
}
}
#[cfg(all(test, unix))]
mod run_dir_privacy_tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use subc_test_support::TestTempDir;
#[test]
fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
let temp = TestTempDir::new("subc-run-dir-privacy");
let created = temp.path().join("cortexkit").join("run");
super::ensure_directory_private(&created).expect("create run dir");
let mode = fs::metadata(&created)
.expect("stat created")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o700,
"observable a run directory this code creates must be 0700, got {mode:o}"
);
fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
let widened = fs::metadata(&created)
.expect("stat widened")
.permissions()
.mode()
& 0o777;
assert_eq!(
widened, 0o755,
"observable the fixture must actually be wide before the tighten"
);
super::ensure_directory_private(&created).expect("tighten run dir");
let mode = fs::metadata(&created)
.expect("stat tightened")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o700,
"observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn abs(posix: &str) -> PathBuf {
if cfg!(windows) {
PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
} else {
PathBuf::from(posix)
}
}
#[test]
fn default_data_home_matches_golden_fixture() {
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let doc: serde_json::Value =
serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
.expect("golden parses");
let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
let saved: Vec<(&str, Option<std::ffi::OsString>)> =
vars.iter().map(|v| (*v, env::var_os(v))).collect();
let platform_matches =
|p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
let mut ran = 0usize;
for case in doc["cases"].as_array().expect("cases array") {
let name = case["name"].as_str().expect("name");
if !platform_matches(case["platform"].as_str().expect("platform")) {
continue;
}
for v in vars {
env::remove_var(v);
}
for (k, v) in case["env"].as_object().expect("env map") {
env::set_var(k, v.as_str().expect("env value"));
}
let got = default_data_home();
assert_eq!(
got.to_string_lossy(),
case["expect"].as_str().expect("expect"),
"golden case '{name}' diverged"
);
ran += 1;
}
assert!(
ran >= 6,
"only {ran} golden cases ran; fixture or filter broken"
);
for (k, v) in saved {
match v {
Some(val) => env::set_var(k, val),
None => env::remove_var(k),
}
}
}
#[test]
fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
let error = daemon_run_dir_from(data_home.clone())
.expect_err("a relative data home must be refused");
assert_eq!(
error,
DaemonRunDirError::RelativeDataHome {
data_home: data_home.clone()
}
);
let message = error.to_string();
assert!(
message.contains("XDG_DATA_HOME") && message.contains("HOME"),
"the refusal must name the variables to set: {message}"
);
}
}
#[test]
fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
assert!(data_home.is_absolute());
assert_eq!(
daemon_run_dir_from(data_home.clone()),
Ok(data_home.join("cortexkit").join("run"))
);
}
#[test]
fn default_config_home_matches_golden_fixture() {
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let doc: serde_json::Value =
serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
.expect("golden parses");
let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
let saved: Vec<(&str, Option<std::ffi::OsString>)> =
vars.iter().map(|v| (*v, env::var_os(v))).collect();
let platform_matches =
|p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
let mut ran = 0usize;
for case in doc["cases"].as_array().expect("cases array") {
let name = case["name"].as_str().expect("name");
if !platform_matches(case["platform"].as_str().expect("platform")) {
continue;
}
for v in vars {
env::remove_var(v);
}
for (k, v) in case["env"].as_object().expect("env map") {
env::set_var(k, v.as_str().expect("env value"));
}
let got = default_config_home();
assert_eq!(
got.to_string_lossy(),
case["expect"].as_str().expect("expect"),
"golden case '{name}' diverged"
);
ran += 1;
}
assert!(
ran >= 6,
"only {ran} golden cases ran; fixture or filter broken"
);
for (k, v) in saved {
match v {
Some(val) => env::set_var(k, val),
None => env::remove_var(k),
}
}
}
#[test]
fn relative_storage_data_home_is_refused_at_parse() {
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let path = Path::new("/golden/subc.jsonc");
let doc =
r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
assert!(
matches!(&err, DaemonConfigError::InvalidValue { message, .. }
if message.contains("relative path relative/home")),
"wrong refusal: {err:?}"
);
let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
let saved: Vec<(&str, Option<std::ffi::OsString>)> =
vars.iter().map(|v| (*v, env::var_os(v))).collect();
for v in vars {
env::remove_var(v);
}
let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
let err = parse_doc(doc, path).expect_err("no home in env must refuse");
assert!(
matches!(&err, DaemonConfigError::InvalidValue { message, .. }
if message.contains("no absolute XDG_DATA_HOME")),
"wrong refusal: {err:?}"
);
let want = abs("/abs/home");
let doc = format!(
r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
serde_json::to_string(&want).expect("json path")
);
let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
assert!(matches!(
cfg.storage,
Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
));
for (k, v) in saved {
match v {
Some(val) => env::set_var(k, val),
None => env::remove_var(k),
}
}
}
#[test]
fn restart_required_sections_are_the_rescan_cannot_apply_set() {
assert_eq!(
RestartRequiredSection::ALL.map(RestartRequiredSection::label),
[
"port",
"storage",
"admission_facts_carrier_module_id",
"admission_facts_targets",
]
);
}
#[test]
fn no_storage_section_yields_none() {
let config = parse_doc(
r#"{ "version": 1, "modules": {} }"#,
Path::new("/tmp/subc.jsonc"),
)
.expect("parse");
assert_eq!(config.storage, None);
}
#[test]
fn sqlite_storage_parses_with_explicit_data_home() {
let config = parse_doc(
&format!(
r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
serde_json::to_string(&abs("/data")).expect("json path")
),
Path::new("/tmp/subc.jsonc"),
)
.expect("parse");
assert_eq!(
config.storage,
Some(StorageConfig::Sqlite {
data_home: abs("/data")
})
);
}
#[test]
fn sqlite_storage_defaults_data_home_when_omitted() {
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
let config = parse_doc(
r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
Path::new("/tmp/subc.jsonc"),
)
.expect("parse");
std::env::remove_var("XDG_DATA_HOME");
assert_eq!(
config.storage,
Some(StorageConfig::Sqlite {
data_home: abs("/forced/data/home")
})
);
}
#[test]
fn descriptor_for_matches_store_types_shape() {
let cfg = StorageConfig::Sqlite {
data_home: PathBuf::from("/data"),
};
let descriptor = cfg.descriptor_for("alfonso-routing");
assert_eq!(
descriptor,
serde_json::json!({
"module_id": "alfonso-routing",
"storage_namespace": "default",
"isolation": { "kind": "module" },
"backend": {
"backend": "sqlite",
"path": "/data/cortexkit/alfonso-routing/store.db"
}
})
);
}
#[test]
fn path_hazard_module_id_refuses_config_parse() {
let path = Path::new("/tmp/subc.jsonc");
let err = parse_doc(
r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
path,
)
.expect_err("separator-bearing module id must refuse");
let text = format!("{err}");
assert!(
text.contains("not usable as a path component"),
"refusal must name the hazard: {text}"
);
}
#[test]
fn drain_timeout_resolves_module_over_daemon_over_absent() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"
{
"version": 1,
"drain_timeout_ms": 45000,
"modules": {
"fast": { "program": "fast", "drain_timeout_ms": 0 },
"slow": { "program": "slow", "drain_timeout_ms": 120000 },
"inherits": { "program": "inherits" }
}
}
"#,
path,
)
.unwrap();
let by_id = |id: &str| {
config
.modules
.iter()
.find(|m| m.module_id == id)
.unwrap()
.drain_timeout_ms
};
assert_eq!(by_id("fast"), Some(0));
assert_eq!(by_id("slow"), Some(120_000));
assert_eq!(by_id("inherits"), Some(45_000));
assert_eq!(config.drain_timeout_ms, Some(45_000));
}
#[test]
fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
path,
)
.unwrap();
assert_eq!(config.modules[0].drain_timeout_ms, None);
assert_eq!(config.drain_timeout_ms, None);
}
#[test]
fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"
{
"version": 1,
"route_bind_relay_timeout_ms": 30000,
"modules": {
"tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
"loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
"inherits": { "program": "inherits" }
}
}
"#,
path,
)
.unwrap();
let by_id = |id: &str| {
config
.modules
.iter()
.find(|m| m.module_id == id)
.unwrap()
.route_bind_relay_timeout_ms
};
assert_eq!(by_id("tight"), Some(5_000));
assert_eq!(by_id("loose"), Some(60_000));
assert_eq!(by_id("inherits"), Some(30_000));
assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
}
#[test]
fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
let path = Path::new("/tmp/subc.jsonc");
for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
let doc = format!(
r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
);
let err = parse_doc(&doc, path).expect_err(bad);
let text = format!("{err}");
assert!(
text.contains(&format!("{bad:?}")),
"must name the key: {text}"
);
assert!(
text.contains("logger name"),
"must say what a key is: {text}"
);
}
let ok = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
path,
);
assert!(ok.is_ok(), "{ok:?}");
}
#[test]
fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
path,
)
.unwrap();
let log = config.modules[0].log.as_ref().unwrap();
assert_eq!(
log.filter_spec("synapse"),
"warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
);
}
#[test]
fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
path,
)
.unwrap();
assert_eq!(
config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
cortexkit_log::SegmentRetention::default().alarm_segment_mb
);
let err = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
path,
)
.expect_err("zero alarm must refuse");
assert!(format!("{err}").contains("alarm_segment_mb"));
}
#[test]
fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
let path = Path::new("/tmp/subc.jsonc");
let err = parse_doc(
r#"
{
"version": 1,
"route_bind_relay_timeout_ms": 0,
"modules": { "m": { "program": "m" } }
}
"#,
path,
)
.expect_err("a daemon-wide zero budget must refuse parse");
let text = format!("{err}");
assert!(
text.contains("route_bind_relay_timeout_ms"),
"error must name the offending key: {text}"
);
assert!(
text.contains("enabled: false"),
"error must name the remedy (enable false): {text}"
);
}
#[test]
fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
let path = Path::new("/tmp/subc.jsonc");
let err = parse_doc(
r#"
{
"version": 1,
"modules": {
"good": { "program": "good" },
"broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
}
}
"#,
path,
)
.expect_err("a per-module zero budget must refuse parse");
let text = format!("{err}");
assert!(
text.contains("route_bind_relay_timeout_ms"),
"error must name the offending key: {text}"
);
assert!(
text.contains("broken"),
"error must name the offending module id: {text}"
);
assert!(
text.contains("enabled: false"),
"error must name the remedy (enable false): {text}"
);
}
#[test]
fn drain_timeout_zero_still_parses_for_wedge_bounces() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"
{
"version": 1,
"drain_timeout_ms": 0,
"modules": {
"wedge": { "program": "wedge", "drain_timeout_ms": 0 }
}
}
"#,
path,
)
.expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
let wedge = config
.modules
.iter()
.find(|m| m.module_id == "wedge")
.unwrap();
assert_eq!(wedge.drain_timeout_ms, Some(0));
assert_eq!(config.drain_timeout_ms, Some(0));
}
#[test]
fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
path,
)
.unwrap();
assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
assert_eq!(config.route_bind_relay_timeout_ms, None);
}
#[test]
fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
path,
)
.unwrap();
assert_eq!(config.modules[0].restart.max_restarts, 3);
assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
assert_eq!(
config.modules[0].restart.backoff,
Duration::from_millis(100)
);
assert_eq!(
config.modules[0].restart.max_backoff,
Duration::from_secs(30)
);
}
#[test]
fn a_restart_block_resolves_each_key_independently() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"
{
"version": 1,
"modules": {
"all": {
"program": "all",
"restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
},
"window-only": {
"program": "window-only",
"restart": { "window_secs": 7200 }
},
"never": {
"program": "never",
"restart": { "max_restarts": 0 }
}
}
}
"#,
path,
)
.unwrap();
let by_id = |id: &str| {
config
.modules
.iter()
.find(|m| m.module_id == id)
.unwrap()
.restart
};
let all = by_id("all");
assert_eq!(all.max_restarts, 5);
assert_eq!(all.window, Duration::from_secs(60));
assert_eq!(all.backoff, Duration::from_millis(250));
assert_eq!(all.max_backoff, Duration::from_secs(5));
let window_only = by_id("window-only");
assert_eq!(window_only.max_restarts, 3);
assert_eq!(window_only.window, Duration::from_secs(7_200));
assert_eq!(window_only.backoff, Duration::from_millis(100));
assert_eq!(window_only.max_backoff, Duration::from_secs(30));
assert_eq!(by_id("never").max_restarts, 0);
}
#[test]
fn restart_window_zero_is_refused_by_name() {
let path = Path::new("/tmp/subc.jsonc");
let err = parse_doc(
r#"
{
"version": 1,
"modules": {
"good": { "program": "good" },
"broken": { "program": "broken", "restart": { "window_secs": 0 } }
}
}
"#,
path,
)
.expect_err("a zero crash window must refuse parse");
assert!(
matches!(err, DaemonConfigError::InvalidValue { .. }),
"a zero window is an invalid value, not a parse failure: {err:?}"
);
let text = format!("{err}");
assert!(
text.contains("restart.window_secs"),
"error must name the offending key: {text}"
);
assert!(
text.contains("broken"),
"error must name the offending module id: {text}"
);
assert!(
text.contains("max_restarts: 0"),
"error must name the setting that actually stops restarts: {text}"
);
}
#[test]
fn restart_max_backoff_below_backoff_is_refused_by_name() {
let path = Path::new("/tmp/subc.jsonc");
let err = parse_doc(
r#"
{
"version": 1,
"modules": {
"broken": {
"program": "broken",
"restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
}
}
}
"#,
path,
)
.expect_err("a maximum below the base backoff must refuse parse");
assert!(
matches!(err, DaemonConfigError::InvalidValue { .. }),
"an invalid restart bound must be an InvalidValue: {err:?}"
);
let text = format!("{err}");
assert!(
text.contains("restart.max_backoff_ms"),
"error must name max_backoff_ms: {text}"
);
assert!(
text.contains("restart.backoff_ms"),
"error must name backoff_ms: {text}"
);
assert!(
text.contains("broken"),
"error must name the offending module id: {text}"
);
}
#[test]
fn parse_jsonc_defaults_and_ignores_unknown_fields() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"
{
// forward-compatible root field
"version": 1,
"unknown": { "ignored": true },
"modules": {
"aft": {
"program": "aft",
"args": ["module",],
"env": { "A": "B", },
"future": 42,
},
"disabled": { "program": "disabled", "enabled": false }
},
}
"#,
path,
)
.unwrap();
assert_eq!(config.port, None);
assert_eq!(config.modules.len(), 2);
assert_eq!(config.modules[0].module_id, "aft");
assert_eq!(config.modules[0].program, PathBuf::from("aft"));
assert_eq!(config.modules[0].args, ["module"]);
assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
assert!(config.modules[0].enabled);
assert!(config.modules[0].reserved_prefixes.is_empty());
assert_eq!(config.modules[0].health, HealthConfig::default());
assert!(!config.modules[1].enabled);
}
#[test]
fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
let path = Path::new("/tmp/subc.jsonc");
let config = parse_doc(
r#"{
"version": 1,
"reserved_capabilities": {
"credentials-provider/v1": "future-vault"
},
"modules": {}
}"#,
path,
)
.expect("a binding may predate its provider installation");
assert_eq!(
config.reserved_capabilities,
BTreeMap::from([(
"credentials-provider/v1".to_string(),
"future-vault".to_string()
)])
);
let error = parse_doc(
r#"{
"version": 1,
"reserved_capabilities": { "Credentials/v1": "vault" },
"modules": {}
}"#,
path,
)
.expect_err("reserved capabilities use the capability identifier grammar");
assert!(error.to_string().contains("reserved_capabilities key"));
}
#[test]
fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
let parse = |module_body: &str| {
parse_doc(
&format!(
r#"{{
"version": 1,
"modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
}}"#
),
Path::new("subc.jsonc"),
)
.expect("module parses")
.modules
.remove(0)
};
let absent = parse("");
let explicit = parse(r#", "protocol": "subc""#);
let none = parse(r#", "protocol": "none""#);
assert_eq!(absent.protocol, ModuleProtocol::Subc);
assert_eq!(explicit.protocol, ModuleProtocol::Subc);
assert_eq!(
absent, explicit,
"an absent protocol key must produce exactly the module an explicit subc does"
);
assert_eq!(none.protocol, ModuleProtocol::None);
assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
}
#[test]
fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
let parse = |module_body: &str| {
parse_doc(
&format!(
r#"{{
"version": 1,
"modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
}}"#
),
Path::new("subc.jsonc"),
)
};
let absent = parse("").unwrap().modules.remove(0);
assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
assert!(typo.to_string().contains("sfae"), "{typo}");
}
#[test]
fn the_spawn_role_is_refused_as_a_configured_env_key() {
let error = parse_doc(
r#"{
"version": 1,
"modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
}"#,
Path::new("subc.jsonc"),
)
.expect_err("SUBC_SPAWN_ROLE must not be configurable");
assert!(
matches!(error, DaemonConfigError::InvalidValue { .. }),
"expected InvalidValue, got {error:?}"
);
assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
}
#[test]
fn an_unsupported_protocol_value_is_refused_by_name() {
let error = parse_doc(
r#"{
"version": 1,
"modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
}"#,
Path::new("subc.jsonc"),
)
.expect_err("an unknown protocol must not fall back to a default");
assert!(
matches!(error, DaemonConfigError::InvalidValue { .. }),
"expected InvalidValue, got {error:?}"
);
let message = error.to_string();
assert!(
message.contains("grpc"),
"the refusal must name the offending value: {message}"
);
assert!(
message.contains("nats"),
"the refusal must name the module so it can be found in the file: {message}"
);
}
#[test]
fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
let error = parse_doc(
r#"{
"version": 1,
"modules": {
"nats": { "program": "nats-server", "protocol": "none", "reserved": true }
}
}"#,
Path::new("subc.jsonc"),
)
.expect_err("a reservation that can never be checked must not parse");
assert!(
matches!(error, DaemonConfigError::InvalidValue { .. }),
"expected InvalidValue, got {error:?}"
);
let message = error.to_string();
assert!(
message.contains("nats") && message.contains("reserved"),
"the refusal must name the module and the offending key: {message}"
);
assert!(
message.contains("HELLO") || message.contains("never registers"),
"the refusal must say WHY the pair cannot work: {message}"
);
}
#[test]
fn reserved_prefixes_parse_for_reserved_modules() {
let config = parse_doc(
r#"
{
"version": 1,
"modules": {
"federation": {
"program": "fed",
"reserved": true,
"reserved_prefixes": ["fed:"]
}
}
}
"#,
Path::new("subc.jsonc"),
)
.unwrap();
assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
}
#[test]
fn reserved_prefixes_reject_bad_boundaries_and_owners() {
let missing_delimiter = parse_doc(
r#"{
"version": 1,
"modules": {
"federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
}
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(
missing_delimiter,
DaemonConfigError::InvalidValue { .. }
));
let non_reserved_owner = parse_doc(
r#"{
"version": 1,
"modules": {
"federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
}
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(
non_reserved_owner,
DaemonConfigError::InvalidValue { .. }
));
}
#[test]
fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
let overlap = parse_doc(
r#"{
"version": 1,
"modules": {
"fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
"sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
}
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
let exact_collision = parse_doc(
r#"{
"version": 1,
"modules": {
"federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
"fed:special": { "program": "special" }
}
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(
exact_collision,
DaemonConfigError::InvalidValue { .. }
));
}
#[test]
fn health_config_parses_and_ignores_unknown_fields() {
let config = parse_doc(
r#"
{
"version": 1,
"modules": {
"aft": {
"program": "aft",
"health": {
"cadence_ms": 100,
"deadline_ms": 20,
"failure_threshold": 2,
"on_degraded": "report",
"on_failing": "restart",
"critical": true,
"future": "ignored"
}
}
}
}
"#,
Path::new("subc.jsonc"),
)
.unwrap();
let health = config.modules[0].health;
assert_eq!(health.cadence, std::time::Duration::from_millis(100));
assert_eq!(health.deadline, std::time::Duration::from_millis(20));
assert_eq!(health.failure_threshold, 2);
assert_eq!(health.on_degraded, HealthAction::Report);
assert_eq!(health.on_failing, HealthAction::Restart);
assert!(health.critical);
}
#[test]
fn health_config_rejects_bad_enum_and_non_positive_numbers() {
let bad_enum = parse_doc(
r#"{
"version": 1,
"modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
let zero = parse_doc(
r#"{
"version": 1,
"modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
}
#[test]
fn admission_facts_carrier_requires_non_empty_targets() {
let missing_targets = parse_doc(
r#"{
"version": 1,
"admission_facts_carrier_module_id": "fed",
"modules": { "fed": { "program": "fed", "reserved": true } }
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(
matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
if message.contains("must be present")),
"expected the presence rule, got: {missing_targets:?}"
);
let empty_targets = parse_doc(
r#"{
"version": 1,
"admission_facts_carrier_module_id": "fed",
"admission_facts_targets": [""],
"modules": { "fed": { "program": "fed", "reserved": true } }
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(
matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
if message.contains("must be non-empty")),
"expected the non-empty rule, got: {empty_targets:?}"
);
}
#[test]
fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
for module in [
r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
] {
let doc = format!(
r#"{{
"version": 1,
"admission_facts_carrier_module_id": "fed",
"admission_facts_targets": ["target"],
"modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
}}"#
);
let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
assert!(
matches!(&err, DaemonConfigError::InvalidValue { message, .. }
if message.contains("enabled reserved module")),
"expected the enabled-and-reserved rule, got: {err:?}"
);
}
let absent = parse_doc(
r#"{
"version": 1,
"admission_facts_carrier_module_id": "missing",
"admission_facts_targets": ["target"],
"modules": { "target": { "program": "target" } }
}"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(
matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
if message.contains("must name a configured module")),
"expected the configured-module rule, got: {absent:?}"
);
}
#[test]
fn reject_unsupported_version() {
let err = parse_doc(
r#"{ "version": 2, "modules": {} }"#,
Path::new("subc.jsonc"),
)
.unwrap_err();
assert!(matches!(
err,
DaemonConfigError::UnsupportedVersion { version: 2, .. }
));
}
#[test]
fn reject_unterminated_block_comment() {
let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
}
}