use std::{
collections::{BTreeSet, HashMap},
env, fs,
path::{Path, PathBuf},
time::Duration,
};
use regex::Regex;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use strum_macros::AsRefStr;
use crate::{
error::ProcessManagerError,
metrics::{MetricsSettings, SpilloverSettings},
};
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub version: String,
pub services: HashMap<String, ServiceConfig>,
pub project_dir: Option<String>,
pub env: Option<EnvConfig>,
#[serde(default)]
pub metrics: MetricsConfig,
}
const METRICS_DEFAULT_RETENTION_MINUTES: u64 = 720; const METRICS_DEFAULT_SAMPLE_INTERVAL_SECS: u64 = 1;
const METRICS_DEFAULT_MAX_MEMORY_BYTES: usize = 10 * 1024 * 1024;
const METRICS_DEFAULT_SPILLOVER_SEGMENT_BYTES: u64 = 256 * 1024;
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
pub struct MetricsConfig {
pub retention_minutes: u64,
pub sample_interval_secs: u64,
pub max_memory_bytes: usize,
pub spillover_path: Option<String>,
pub spillover_max_bytes: Option<u64>,
pub spillover_segment_bytes: Option<u64>,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
retention_minutes: METRICS_DEFAULT_RETENTION_MINUTES,
sample_interval_secs: METRICS_DEFAULT_SAMPLE_INTERVAL_SECS,
max_memory_bytes: METRICS_DEFAULT_MAX_MEMORY_BYTES,
spillover_path: None,
spillover_max_bytes: None,
spillover_segment_bytes: None,
}
}
}
impl MetricsConfig {
pub fn to_settings(&self, project_dir: Option<&Path>) -> MetricsSettings {
let retention_minutes = self.retention_minutes.max(1);
let sample_interval_secs = self.sample_interval_secs.clamp(1, 60);
let max_memory_bytes = self.max_memory_bytes.max(128 * 1024);
let spillover = self.spillover_path.as_ref().and_then(|raw| {
let mut path = PathBuf::from(raw);
if path.is_relative()
&& let Some(base) = project_dir
{
path = base.join(path);
}
let max_bytes = self.spillover_max_bytes.unwrap_or(6 * 1024 * 1024);
if max_bytes == 0 {
return None;
}
Some(SpilloverSettings {
directory: path,
max_bytes,
segment_bytes: self
.spillover_segment_bytes
.unwrap_or(METRICS_DEFAULT_SPILLOVER_SEGMENT_BYTES),
})
});
MetricsSettings {
retention: Duration::from_secs(retention_minutes * 60),
sample_interval: Duration::from_secs(sample_interval_secs),
max_memory_bytes,
spillover,
}
}
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
#[serde(untagged)]
pub enum SkipConfig {
Flag(bool),
Command(String),
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SpawnMode {
Static,
Dynamic,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, Default)]
pub struct SpawnConfig {
pub mode: Option<SpawnMode>,
pub limits: Option<SpawnLimitsConfig>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, Default)]
pub struct SpawnLimitsConfig {
pub children: Option<u32>,
pub depth: Option<u32>,
pub descendants: Option<u32>,
pub total_memory: Option<String>,
pub termination_policy: Option<TerminationPolicy>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TerminationPolicy {
Cascade,
Orphan,
Reparent,
}
#[derive(Debug, Default, Deserialize, Clone, serde::Serialize)]
pub struct ServiceConfig {
pub command: String,
pub env: Option<EnvConfig>,
pub user: Option<String>,
pub group: Option<String>,
#[serde(default, rename = "supplementary_groups")]
pub supplementary_groups: Option<Vec<String>>,
pub limits: Option<LimitsConfig>,
pub capabilities: Option<Vec<String>>,
pub isolation: Option<IsolationConfig>,
pub restart_policy: Option<String>,
pub backoff: Option<String>,
pub max_restarts: Option<u32>,
pub depends_on: Option<Vec<String>>,
pub deployment: Option<DeploymentConfig>,
pub hooks: Option<Hooks>,
pub cron: Option<CronConfig>,
pub skip: Option<SkipConfig>,
pub spawn: Option<SpawnConfig>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, Default)]
pub struct LimitsConfig {
pub nofile: Option<LimitValue>,
pub nproc: Option<LimitValue>,
pub memlock: Option<LimitValue>,
pub nice: Option<i32>,
pub cpu_affinity: Option<Vec<u16>>,
pub cgroup: Option<CgroupConfig>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize, Default)]
pub struct CgroupConfig {
pub root: Option<String>,
pub memory_max: Option<String>,
pub cpu_max: Option<String>,
pub cpu_weight: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum LimitValue {
Fixed(u64),
Unlimited,
}
impl<'de> Deserialize<'de> for LimitValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct LimitVisitor;
impl<'de> serde::de::Visitor<'de> for LimitVisitor {
type Value = LimitValue;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"a non-negative integer, an optional size suffix (e.g. 512M), or 'unlimited'",
)
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(LimitValue::Fixed(value))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match parse_limit(value) {
Ok(bytes) => Ok(LimitValue::Fixed(bytes)),
Err(LimitParseError::Unlimited) => Ok(LimitValue::Unlimited),
Err(LimitParseError::Invalid(_)) => {
Err(E::invalid_value(serde::de::Unexpected::Str(value), &self))
}
}
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if value < 0 {
return Err(E::invalid_value(
serde::de::Unexpected::Signed(value),
&"non-negative integer",
));
}
Ok(LimitValue::Fixed(value as u64))
}
}
deserializer.deserialize_any(LimitVisitor)
}
}
#[derive(Debug)]
enum LimitParseError {
Unlimited,
Invalid(String),
}
fn parse_limit(input: &str) -> Result<u64, LimitParseError> {
let trimmed = input.trim();
if trimmed.eq_ignore_ascii_case("unlimited") {
return Err(LimitParseError::Unlimited);
}
let normalized = trimmed.replace('_', "");
let without_bytes = normalized.trim_end_matches(&['B', 'b'][..]);
let (number_part, factor) = match without_bytes.chars().last() {
Some(suffix) if suffix.is_ascii_alphabetic() => {
let len = without_bytes.len() - suffix.len_utf8();
let number_part = &without_bytes[..len];
let multiplier = match suffix.to_ascii_uppercase() {
'K' => 1u128 << 10,
'M' => 1u128 << 20,
'G' => 1u128 << 30,
'T' => 1u128 << 40,
_ => return Err(LimitParseError::Invalid(trimmed.to_string())),
};
(number_part.trim(), multiplier)
}
_ => (without_bytes.trim(), 1u128),
};
if number_part.is_empty() {
return Err(LimitParseError::Invalid(trimmed.to_string()));
}
let value = number_part
.parse::<u128>()
.map_err(|_| LimitParseError::Invalid(trimmed.to_string()))?;
let bytes = value
.checked_mul(factor)
.ok_or_else(|| LimitParseError::Invalid(trimmed.to_string()))?;
u64::try_from(bytes).map_err(|_| LimitParseError::Invalid(trimmed.to_string()))
}
impl std::fmt::Display for LimitParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LimitParseError::Unlimited => write!(f, "value represents unlimited"),
LimitParseError::Invalid(value) => write!(f, "invalid limit value '{value}'"),
}
}
}
impl std::error::Error for LimitParseError {}
#[derive(Debug, Deserialize, Clone, serde::Serialize, Default)]
pub struct IsolationConfig {
pub network: Option<bool>,
pub mount: Option<bool>,
pub pid: Option<bool>,
pub user: Option<bool>,
pub seccomp: Option<String>,
pub apparmor_profile: Option<String>,
pub selinux_context: Option<String>,
pub private_devices: Option<bool>,
pub private_tmp: Option<bool>,
}
impl ServiceConfig {
pub fn compute_hash(&self) -> String {
let json = serde_json::to_string(self)
.expect("ServiceConfig should always be serializable");
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
let result = hasher.finalize();
format!(
"{:016x}",
u64::from_be_bytes(result[0..8].try_into().unwrap())
)
}
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct DeploymentConfig {
pub strategy: Option<String>,
pub pre_start: Option<String>,
pub health_check: Option<HealthCheckConfig>,
pub grace_period: Option<String>,
pub blue_green: Option<BlueGreenDeploymentConfig>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct BlueGreenDeploymentConfig {
pub env_var: Option<String>,
pub slots: Vec<String>,
pub switch_command: Option<String>,
pub candidate_health_check_url: Option<String>,
pub switch_verify_url: Option<String>,
pub state_path: Option<String>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct HealthCheckConfig {
pub url: String,
pub timeout: Option<String>,
pub retries: Option<u32>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvConfig {
pub file: Option<String>,
pub vars: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
struct RawEnvConfig {
file: Option<String>,
vars: Option<HashMap<String, String>>,
#[serde(flatten)]
entries: HashMap<String, String>,
}
impl<'de> Deserialize<'de> for EnvConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawEnvConfig::deserialize(deserializer)?;
let mut vars = raw.entries;
if let Some(explicit_vars) = raw.vars {
vars.extend(explicit_vars);
}
Ok(Self {
file: raw.file,
vars: if vars.is_empty() { None } else { Some(vars) },
})
}
}
impl EnvConfig {
pub fn path(&self, base: &Path) -> Option<PathBuf> {
self.file.as_ref().map(|f| {
let path = Path::new(f);
if path.is_absolute() || path.exists() {
path.to_path_buf()
} else {
base.join(path)
}
})
}
pub fn merge(
root: Option<&EnvConfig>,
service: Option<&EnvConfig>,
) -> Option<EnvConfig> {
match (root, service) {
(None, None) => None,
(Some(r), None) => Some(r.clone()),
(None, Some(s)) => Some(s.clone()),
(Some(root_cfg), Some(service_cfg)) => {
let mut merged_vars = root_cfg.vars.clone().unwrap_or_default();
if let Some(service_vars) = &service_cfg.vars {
merged_vars.extend(service_vars.clone());
}
let file = service_cfg.file.clone().or_else(|| root_cfg.file.clone());
Some(EnvConfig {
file,
vars: if merged_vars.is_empty() {
None
} else {
Some(merged_vars)
},
})
}
}
}
}
#[derive(Debug, Clone, Copy, AsRefStr)]
#[strum(serialize_all = "snake_case")]
pub enum HookStage {
OnStart,
OnStop,
OnRestart,
}
#[derive(Debug, Clone, Copy, AsRefStr)]
#[strum(serialize_all = "snake_case")]
pub enum HookOutcome {
Success,
Error,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct HookAction {
pub command: String,
pub timeout: Option<String>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct HookLifecycleConfig {
pub success: Option<HookAction>,
pub error: Option<HookAction>,
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct Hooks {
pub on_start: Option<HookLifecycleConfig>,
pub on_stop: Option<HookLifecycleConfig>,
#[serde(default)]
pub on_restart: Option<HookLifecycleConfig>,
}
impl Hooks {
pub fn action(&self, stage: HookStage, outcome: HookOutcome) -> Option<&HookAction> {
let lifecycle = match stage {
HookStage::OnStart => self.on_start.as_ref(),
HookStage::OnStop => self.on_stop.as_ref(),
HookStage::OnRestart => self.on_restart.as_ref(),
}?;
match outcome {
HookOutcome::Success => lifecycle.success.as_ref(),
HookOutcome::Error => lifecycle.error.as_ref(),
}
}
}
#[derive(Debug, Deserialize, Clone, serde::Serialize)]
pub struct CronConfig {
pub expression: String,
pub timezone: Option<String>,
}
impl Config {
pub fn service_hashes(&self) -> HashMap<String, String> {
self.services
.iter()
.map(|(name, config)| (name.clone(), config.compute_hash()))
.collect()
}
pub fn get_service_hash(&self, service_name: &str) -> Option<String> {
self.services
.get(service_name)
.map(|cfg| cfg.compute_hash())
}
pub fn service_start_order(&self) -> Result<Vec<String>, ProcessManagerError> {
let mut indegree: HashMap<String, usize> =
self.services.keys().map(|name| (name.clone(), 0)).collect();
let mut graph: HashMap<String, Vec<String>> = HashMap::new();
for (service, cfg) in &self.services {
if let Some(deps) = &cfg.depends_on {
for dep in deps {
if !self.services.contains_key(dep) {
return Err(ProcessManagerError::UnknownDependency {
service: service.clone(),
dependency: dep.clone(),
});
}
*indegree.get_mut(service).expect("service must exist") += 1;
graph.entry(dep.clone()).or_default().push(service.clone());
}
}
}
let mut ready: BTreeSet<String> = indegree
.iter()
.filter(|&(_, °)| deg == 0)
.map(|(name, _)| name.clone())
.collect();
let mut order = Vec::with_capacity(self.services.len());
while let Some(service) = ready.pop_first() {
order.push(service.clone());
if let Some(children) = graph.get(&service) {
for child in children {
if let Some(deg) = indegree.get_mut(child) {
*deg -= 1;
if *deg == 0 {
ready.insert(child.clone());
}
}
}
}
}
if order.len() != self.services.len() {
let remaining: Vec<String> = indegree
.into_iter()
.filter(|(_, deg)| *deg > 0)
.map(|(name, _)| name)
.collect();
return Err(ProcessManagerError::DependencyCycle {
cycle: remaining.join(" -> "),
});
}
Ok(order)
}
pub fn reverse_dependencies(&self) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for (service, cfg) in &self.services {
if let Some(deps) = &cfg.depends_on {
for dep in deps {
map.entry(dep.clone()).or_default().push(service.clone());
}
}
}
for dependents in map.values_mut() {
dependents.sort();
}
map
}
}
fn expand_env_vars(input: &str) -> Result<String, ProcessManagerError> {
let re = Regex::new(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?").unwrap();
let result = re.replace_all(input, |caps: ®ex::Captures| {
let var_name = &caps[1];
match env::var(var_name) {
Ok(value) => value,
Err(_) => panic!("Missing environment variable: {var_name}"),
}
});
Ok(result.to_string())
}
fn load_env_file(path: &str) -> Result<(), ProcessManagerError> {
let content =
fs::read_to_string(path).map_err(ProcessManagerError::ConfigReadError)?;
for line in content.lines() {
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let mut value = value.trim();
if value.starts_with('"') && value.ends_with('"') {
value = &value[1..value.len() - 1];
}
unsafe {
env::set_var(key, value);
}
}
}
Ok(())
}
pub fn load_config(config_path: Option<&str>) -> Result<Config, ProcessManagerError> {
let config_path = config_path.map(Path::new).unwrap_or_else(|| {
if Path::new("systemg.yaml").exists() {
Path::new("systemg.yaml")
} else {
Path::new("sysg.yaml")
}
});
let content = fs::read_to_string(config_path).map_err(|e| {
ProcessManagerError::ConfigReadError(std::io::Error::new(
e.kind(),
format!("{} ({})", e, config_path.display()),
))
})?;
let mut config: Config =
serde_yaml::from_str(&content).map_err(ProcessManagerError::ConfigParseError)?;
let base_path = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
config.project_dir = Some(base_path.to_string_lossy().to_string());
if let Some(env_config) = &config.env
&& let Some(resolved_path) = env_config.path(&base_path)
{
load_env_file(&resolved_path.to_string_lossy())?;
}
if let Some(env_config) = &config.env
&& let Some(vars) = &env_config.vars
{
for (key, value) in vars {
unsafe {
env::set_var(key, value);
}
}
}
for service in config.services.values_mut() {
let merged_env = EnvConfig::merge(config.env.as_ref(), service.env.as_ref());
if let Some(env_config) = &merged_env
&& let Some(resolved_path) = env_config.path(&base_path)
{
load_env_file(&resolved_path.to_string_lossy())?;
}
if let Some(env_config) = &merged_env
&& let Some(vars) = &env_config.vars
{
for (key, value) in vars {
unsafe {
env::set_var(key, value);
}
}
}
service.env = merged_env;
}
let expanded_content = expand_env_vars(&content)?;
let mut config: Config = serde_yaml::from_str(&expanded_content)
.map_err(ProcessManagerError::ConfigParseError)?;
config.project_dir = Some(base_path.to_string_lossy().to_string());
for service in config.services.values_mut() {
service.env = EnvConfig::merge(config.env.as_ref(), service.env.as_ref());
}
config.service_start_order()?;
Ok(config)
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Write};
use tempfile::tempdir;
use super::*;
#[test]
fn test_load_env_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join(".env");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "TEST_KEY=TEST_VALUE").unwrap();
writeln!(file, "ANOTHER_KEY=ANOTHER_VALUE").unwrap();
load_env_file(file_path.to_str().unwrap()).unwrap();
assert_eq!(env::var("TEST_KEY").unwrap(), "TEST_VALUE");
assert_eq!(env::var("ANOTHER_KEY").unwrap(), "ANOTHER_VALUE");
}
#[test]
fn test_load_config_with_absolute_env_path() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("absolute.env");
let mut env_file = File::create(&env_path).unwrap();
writeln!(env_file, "MY_TEST_VAR=HelloWorld").unwrap();
let yaml_path = dir.path().join("config.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
services:
service1:
command: "echo ${{MY_TEST_VAR}}"
env:
file: "{}"
vars:
TEST: "test"
"#,
env_path.to_str().unwrap()
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
let base_path = Path::new(config.project_dir.as_ref().unwrap());
let service = &config.services["service1"];
let resolved = service.env.as_ref().unwrap().path(base_path).unwrap();
assert_eq!(resolved, env_path);
assert!(resolved.is_absolute());
}
#[test]
fn test_load_config_with_relative_env_path() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("relative.env");
let mut env_file = File::create(&env_path).unwrap();
writeln!(env_file, "REL_VAR=42").unwrap();
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
services:
rel_service:
command: "echo ${{REL_VAR}}"
env:
file: "relative.env"
vars:
DB: "local"
"#
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
let service = &config.services["rel_service"];
let base_path = Path::new(config.project_dir.as_ref().unwrap());
assert_eq!(
service.env.as_ref().unwrap().path(base_path).unwrap(),
env_path
);
}
fn minimal_service(depends_on: Option<Vec<&str>>) -> ServiceConfig {
ServiceConfig {
command: "echo ok".into(),
env: None,
user: None,
group: None,
supplementary_groups: None,
limits: None,
capabilities: None,
isolation: None,
restart_policy: None,
backoff: None,
max_restarts: None,
depends_on: depends_on
.map(|deps| deps.into_iter().map(String::from).collect()),
deployment: None,
hooks: None,
cron: None,
skip: None,
spawn: None,
}
}
#[test]
fn service_start_order_resolves_dependencies() {
let mut services = HashMap::new();
services.insert("a".into(), minimal_service(None));
services.insert("b".into(), minimal_service(Some(vec!["a"])));
services.insert("c".into(), minimal_service(Some(vec!["b"])));
let config = Config {
version: "1".into(),
services,
project_dir: None,
env: None,
metrics: MetricsConfig::default(),
};
let order = config.service_start_order().unwrap();
assert_eq!(order, vec!["a", "b", "c"]);
}
#[test]
fn service_start_order_unknown_dependency_error() {
let mut services = HashMap::new();
services.insert("a".into(), minimal_service(Some(vec!["missing"])));
let config = Config {
version: "1".into(),
services,
project_dir: None,
env: None,
metrics: MetricsConfig::default(),
};
match config.service_start_order() {
Err(ProcessManagerError::UnknownDependency {
service,
dependency,
}) => {
assert_eq!(service, "a");
assert_eq!(dependency, "missing");
}
other => panic!("expected unknown dependency error, got {other:?}"),
}
}
#[test]
fn service_start_order_cycle_error() {
let mut services = HashMap::new();
services.insert("a".into(), minimal_service(Some(vec!["b"])));
services.insert("b".into(), minimal_service(Some(vec!["a"])));
let config = Config {
version: "1".into(),
services,
project_dir: None,
env: None,
metrics: MetricsConfig::default(),
};
match config.service_start_order() {
Err(ProcessManagerError::DependencyCycle { cycle }) => {
assert!(cycle.contains("a"));
assert!(cycle.contains("b"));
}
other => panic!("expected dependency cycle error, got {other:?}"),
}
}
#[test]
fn test_env_merge_both_none() {
let result = EnvConfig::merge(None, None);
assert!(result.is_none());
}
#[test]
fn test_env_merge_root_only() {
let root = EnvConfig {
file: Some("root.env".into()),
vars: Some(HashMap::from([("ROOT_VAR".into(), "root_value".into())])),
};
let result = EnvConfig::merge(Some(&root), None).unwrap();
assert_eq!(result.file, Some("root.env".into()));
assert_eq!(
result.vars.as_ref().unwrap().get("ROOT_VAR"),
Some(&"root_value".to_string())
);
}
#[test]
fn test_env_merge_service_only() {
let service = EnvConfig {
file: Some("service.env".into()),
vars: Some(HashMap::from([(
"SERVICE_VAR".into(),
"service_value".into(),
)])),
};
let result = EnvConfig::merge(None, Some(&service)).unwrap();
assert_eq!(result.file, Some("service.env".into()));
assert_eq!(
result.vars.as_ref().unwrap().get("SERVICE_VAR"),
Some(&"service_value".to_string())
);
}
#[test]
fn test_env_merge_service_overrides_root() {
let root = EnvConfig {
file: Some("root.env".into()),
vars: Some(HashMap::from([
("SHARED_VAR".into(), "root_value".into()),
("ROOT_ONLY".into(), "root_only_value".into()),
])),
};
let service = EnvConfig {
file: Some("service.env".into()),
vars: Some(HashMap::from([
("SHARED_VAR".into(), "service_value".into()),
("SERVICE_ONLY".into(), "service_only_value".into()),
])),
};
let result = EnvConfig::merge(Some(&root), Some(&service)).unwrap();
assert_eq!(result.file, Some("service.env".into()));
let vars = result.vars.unwrap();
assert_eq!(vars.get("SHARED_VAR"), Some(&"service_value".to_string()));
assert_eq!(vars.get("ROOT_ONLY"), Some(&"root_only_value".to_string()));
assert_eq!(
vars.get("SERVICE_ONLY"),
Some(&"service_only_value".to_string())
);
}
#[test]
fn test_env_merge_service_file_only_overrides_root() {
let root = EnvConfig {
file: Some("root.env".into()),
vars: Some(HashMap::from([("ROOT_VAR".into(), "root_value".into())])),
};
let service = EnvConfig {
file: Some("service.env".into()),
vars: None,
};
let result = EnvConfig::merge(Some(&root), Some(&service)).unwrap();
assert_eq!(result.file, Some("service.env".into()));
let vars = result.vars.unwrap();
assert_eq!(vars.get("ROOT_VAR"), Some(&"root_value".to_string()));
}
#[test]
fn test_env_config_deserializes_direct_inline_vars() {
let env: EnvConfig = serde_yaml::from_str(
r#"
file: ".env"
RUST_LOG: "debug"
ESPER_ENGINE_SERVICE_URL: "http://127.0.0.1:4100"
"#,
)
.unwrap();
assert_eq!(env.file.as_deref(), Some(".env"));
let vars = env.vars.unwrap();
assert_eq!(vars.get("RUST_LOG"), Some(&"debug".to_string()));
assert_eq!(
vars.get("ESPER_ENGINE_SERVICE_URL"),
Some(&"http://127.0.0.1:4100".to_string())
);
}
#[test]
fn test_env_config_deserializes_nested_and_direct_vars() {
let env: EnvConfig = serde_yaml::from_str(
r#"
file: ".env"
vars:
POSTGRES_URI: "postgres://localhost/db"
RUST_LOG: "debug"
"#,
)
.unwrap();
assert_eq!(env.file.as_deref(), Some(".env"));
let vars = env.vars.unwrap();
assert_eq!(
vars.get("POSTGRES_URI"),
Some(&"postgres://localhost/db".to_string())
);
assert_eq!(vars.get("RUST_LOG"), Some(&"debug".to_string()));
}
#[test]
fn test_load_config_with_root_env() {
let dir = tempdir().unwrap();
let root_env_path = dir.path().join("root.env");
let mut root_env_file = File::create(&root_env_path).unwrap();
writeln!(root_env_file, "ROOT_VAR=from_root_file").unwrap();
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
env:
file: "root.env"
vars:
GLOBAL_VAR: "global_value"
services:
service1:
command: "echo ${{ROOT_VAR}} ${{GLOBAL_VAR}}"
service2:
command: "echo ${{ROOT_VAR}} ${{GLOBAL_VAR}}"
"#
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
for service_name in ["service1", "service2"] {
let service = &config.services[service_name];
let env = service.env.as_ref().unwrap();
let vars = env.vars.as_ref().unwrap();
assert_eq!(vars.get("GLOBAL_VAR"), Some(&"global_value".to_string()));
}
}
#[test]
fn test_load_config_with_direct_service_env_vars() {
let dir = tempdir().unwrap();
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
services:
service1:
command: "echo ok"
env:
RUST_LOG: "debug"
API_URL: "http://127.0.0.1:4100"
"#
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
let service = &config.services["service1"];
let env = service.env.as_ref().unwrap();
let vars = env.vars.as_ref().unwrap();
assert_eq!(vars.get("RUST_LOG"), Some(&"debug".to_string()));
assert_eq!(
vars.get("API_URL"),
Some(&"http://127.0.0.1:4100".to_string())
);
}
#[test]
fn test_load_config_merges_root_and_service_direct_env_vars() {
let dir = tempdir().unwrap();
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
env:
REDIS_URI: "redis://127.0.0.1:6379"
services:
service1:
command: "echo ok"
env:
RUST_LOG: "debug"
"#
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
let service = &config.services["service1"];
let env = service.env.as_ref().unwrap();
let vars = env.vars.as_ref().unwrap();
assert_eq!(
vars.get("REDIS_URI"),
Some(&"redis://127.0.0.1:6379".to_string())
);
assert_eq!(vars.get("RUST_LOG"), Some(&"debug".to_string()));
}
#[test]
fn test_load_config_service_env_overrides_root() {
let dir = tempdir().unwrap();
let root_env_path = dir.path().join("root.env");
let mut root_env_file = File::create(&root_env_path).unwrap();
writeln!(root_env_file, "ROOT_FILE_VAR=root").unwrap();
let service_env_path = dir.path().join("service.env");
let mut service_env_file = File::create(&service_env_path).unwrap();
writeln!(service_env_file, "SERVICE_FILE_VAR=service").unwrap();
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).unwrap();
writeln!(
yaml_file,
r#"
version: "1"
env:
file: "root.env"
vars:
SHARED: "root_value"
ROOT_ONLY: "root"
services:
service1:
command: "echo test"
env:
file: "service.env"
vars:
SHARED: "service_value"
SERVICE_ONLY: "service"
service2:
command: "echo test"
"#
)
.unwrap();
let config = load_config(Some(yaml_path.to_str().unwrap())).unwrap();
let service1 = &config.services["service1"];
let env1 = service1.env.as_ref().unwrap();
assert_eq!(env1.file, Some("service.env".into()));
let vars1 = env1.vars.as_ref().unwrap();
assert_eq!(vars1.get("SHARED"), Some(&"service_value".to_string()));
assert_eq!(vars1.get("ROOT_ONLY"), Some(&"root".to_string()));
assert_eq!(vars1.get("SERVICE_ONLY"), Some(&"service".to_string()));
let service2 = &config.services["service2"];
let env2 = service2.env.as_ref().unwrap();
assert_eq!(env2.file, Some("root.env".into()));
let vars2 = env2.vars.as_ref().unwrap();
assert_eq!(vars2.get("SHARED"), Some(&"root_value".to_string()));
assert_eq!(vars2.get("ROOT_ONLY"), Some(&"root".to_string()));
assert!(vars2.get("SERVICE_ONLY").is_none());
}
#[test]
fn load_config_parses_blue_green_deployment_block() {
let dir = tempdir().expect("tempdir");
let yaml_path = dir.path().join("systemg.yaml");
let mut yaml_file = File::create(&yaml_path).expect("create yaml");
writeln!(
yaml_file,
r#"
version: "1"
services:
web:
command: "python app.py"
deployment:
strategy: "rolling"
blue_green:
env_var: "PORT"
slots: ["8000", "8001"]
switch_command: "echo switch"
candidate_health_check_url: "http://127.0.0.1:{{slot}}/health"
switch_verify_url: "http://127.0.0.1/health"
state_path: ".state/web-slot.json"
"#
)
.expect("write yaml");
let config = load_config(Some(yaml_path.to_str().expect("yaml path")))
.expect("load config");
let deployment = config
.services
.get("web")
.expect("web service")
.deployment
.as_ref()
.expect("deployment");
let blue_green = deployment.blue_green.as_ref().expect("blue_green");
assert_eq!(deployment.strategy.as_deref(), Some("rolling"));
assert_eq!(blue_green.env_var.as_deref(), Some("PORT"));
assert_eq!(blue_green.slots, vec!["8000", "8001"]);
assert_eq!(
blue_green.candidate_health_check_url.as_deref(),
Some("http://127.0.0.1:{slot}/health")
);
assert_eq!(
blue_green.switch_verify_url.as_deref(),
Some("http://127.0.0.1/health")
);
}
#[test]
fn hash_computation_is_stable() {
let config1 = ServiceConfig {
command: "test command".to_string(),
env: None,
user: None,
group: None,
supplementary_groups: None,
limits: None,
capabilities: None,
isolation: None,
restart_policy: Some("always".to_string()),
backoff: Some("5s".to_string()),
max_restarts: Some(3),
depends_on: None,
deployment: None,
hooks: None,
cron: Some(CronConfig {
expression: "0 * * * * *".to_string(),
timezone: Some("UTC".to_string()),
}),
skip: None,
spawn: None,
};
let config2 = ServiceConfig {
command: "test command".to_string(),
env: None,
user: None,
group: None,
supplementary_groups: None,
limits: None,
capabilities: None,
isolation: None,
restart_policy: Some("always".to_string()),
backoff: Some("5s".to_string()),
max_restarts: Some(3),
depends_on: None,
deployment: None,
hooks: None,
cron: Some(CronConfig {
expression: "0 * * * * *".to_string(),
timezone: Some("UTC".to_string()),
}),
skip: None,
spawn: None,
};
let hash1 = config1.compute_hash();
let hash2 = config2.compute_hash();
assert_eq!(
hash1, hash2,
"Identical configs should produce identical hashes"
);
assert_eq!(hash1.len(), 16, "Hash should be 16 characters");
}
#[test]
fn hash_changes_with_config_changes() {
let base_config = ServiceConfig {
command: "test command".to_string(),
env: None,
user: None,
group: None,
supplementary_groups: None,
limits: None,
capabilities: None,
isolation: None,
restart_policy: None,
backoff: None,
max_restarts: None,
depends_on: None,
deployment: None,
hooks: None,
cron: None,
skip: None,
spawn: None,
};
let modified_command = ServiceConfig {
command: "different command".to_string(),
..base_config.clone()
};
let modified_cron = ServiceConfig {
cron: Some(CronConfig {
expression: "*/5 * * * * *".to_string(),
timezone: None,
}),
..base_config.clone()
};
let base_hash = base_config.compute_hash();
let command_hash = modified_command.compute_hash();
let cron_hash = modified_cron.compute_hash();
assert_ne!(
base_hash, command_hash,
"Changing command should change hash"
);
assert_ne!(base_hash, cron_hash, "Adding cron should change hash");
assert_ne!(
command_hash, cron_hash,
"Different changes should produce different hashes"
);
}
#[test]
fn service_rename_preserves_hash() {
let config = ServiceConfig {
command: "echo hello".to_string(),
env: None,
user: None,
group: None,
supplementary_groups: None,
limits: None,
capabilities: None,
isolation: None,
restart_policy: Some("always".to_string()),
backoff: None,
max_restarts: None,
depends_on: None,
deployment: None,
hooks: None,
cron: Some(CronConfig {
expression: "0 * * * * *".to_string(),
timezone: Some("UTC".to_string()),
}),
skip: None,
spawn: None,
};
let hash = config.compute_hash();
assert_eq!(hash.len(), 16);
let renamed_config = config.clone();
let renamed_hash = renamed_config.compute_hash();
assert_eq!(
hash, renamed_hash,
"Hash should be the same after 'renaming' (using same config)"
);
}
#[test]
fn parse_limit_accepts_suffixes() {
let kib = parse_limit("4K").expect("parse 4K");
assert_eq!(kib, 4 * 1024);
let mib = parse_limit("512M").expect("parse 512M");
assert_eq!(mib, 512 * 1024 * 1024);
let gib = parse_limit("1G").expect("parse 1G");
assert_eq!(gib, 1024 * 1024 * 1024);
let plain = parse_limit("1_000").expect("parse underscores");
assert_eq!(plain, 1_000);
}
#[test]
fn parse_limit_rejects_invalid_strings() {
match parse_limit("ten") {
Err(LimitParseError::Invalid(msg)) => assert_eq!(msg, "ten"),
other => panic!("expected invalid error, got {other:?}"),
}
}
}