use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServiceConfig {
pub service: ServiceDef,
#[serde(default)]
pub dependencies: DependencyDef,
#[serde(default)]
pub lifecycle: LifecycleDef,
#[serde(default)]
pub health: Option<HealthDef>,
#[serde(default)]
pub logging: LoggingDef,
}
impl ServiceConfig {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self, ConfigError> {
toml::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))
}
pub fn to_toml(&self) -> Result<String, ConfigError> {
toml::to_string_pretty(self).map_err(|e| ConfigError::Parse(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServiceDef {
pub name: String,
pub exec: String,
#[serde(default)]
pub dir: Option<String>,
#[serde(default)]
pub oneshot: bool,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub class: ServiceClass,
#[serde(default)]
pub critical: bool,
#[serde(default)]
pub ports: Vec<u16>,
#[serde(default)]
pub kill_others: bool,
#[serde(default)]
pub process_filters: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DependencyDef {
#[serde(default)]
pub after: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub wants: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LifecycleDef {
#[serde(default = "default_restart_policy")]
pub restart: RestartPolicy,
#[serde(default = "default_restart_delay_ms")]
pub restart_delay_ms: u64,
#[serde(default = "default_restart_delay_max_ms")]
pub restart_delay_max_ms: u64,
#[serde(default = "default_max_restarts")]
pub max_restarts: u32,
#[serde(default = "default_stability_period_ms")]
pub stability_period_ms: u64,
#[serde(default = "default_start_timeout_ms")]
pub start_timeout_ms: u64,
#[serde(default = "default_stop_timeout_ms")]
pub stop_timeout_ms: u64,
#[serde(default = "default_stop_signal")]
pub stop_signal: String,
}
impl Default for LifecycleDef {
fn default() -> Self {
Self {
restart: default_restart_policy(),
restart_delay_ms: default_restart_delay_ms(),
restart_delay_max_ms: default_restart_delay_max_ms(),
max_restarts: default_max_restarts(),
stability_period_ms: default_stability_period_ms(),
start_timeout_ms: default_start_timeout_ms(),
stop_timeout_ms: default_stop_timeout_ms(),
stop_signal: default_stop_signal(),
}
}
}
fn default_restart_policy() -> RestartPolicy {
RestartPolicy::OnFailure
}
fn default_restart_delay_ms() -> u64 {
1000
}
fn default_restart_delay_max_ms() -> u64 {
300000 }
fn default_max_restarts() -> u32 {
10 }
fn default_stability_period_ms() -> u64 {
30000 }
fn default_start_timeout_ms() -> u64 {
30000
}
fn default_stop_timeout_ms() -> u64 {
10000
}
fn default_stop_signal() -> String {
"SIGTERM".to_string()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
Always,
#[default]
OnFailure,
Never,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
#[default]
Start,
Stop,
Ignore,
}
impl Status {
pub fn should_autostart(&self) -> bool {
matches!(self, Status::Start)
}
pub fn should_stop(&self) -> bool {
matches!(self, Status::Stop)
}
pub fn is_manual(&self) -> bool {
matches!(self, Status::Ignore)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceClass {
#[default]
User,
System,
}
impl ServiceClass {
pub fn is_system(&self) -> bool {
matches!(self, ServiceClass::System)
}
pub fn is_user(&self) -> bool {
matches!(self, ServiceClass::User)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum HealthDef {
Tcp {
target: String,
#[serde(flatten)]
common: HealthCommon,
},
Http {
target: String,
#[serde(default = "default_http_status")]
expect_status: u16,
#[serde(flatten)]
common: HealthCommon,
},
Exec {
target: String,
#[serde(flatten)]
common: HealthCommon,
},
}
fn default_http_status() -> u16 {
200
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HealthCommon {
#[serde(default = "default_health_interval_ms")]
pub interval_ms: u64,
#[serde(default = "default_health_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_health_retries")]
pub retries: u32,
#[serde(default = "default_health_start_period_ms")]
pub start_period_ms: u64,
}
impl Default for HealthCommon {
fn default() -> Self {
Self {
interval_ms: default_health_interval_ms(),
timeout_ms: default_health_timeout_ms(),
retries: default_health_retries(),
start_period_ms: default_health_start_period_ms(),
}
}
}
fn default_health_interval_ms() -> u64 {
10000
}
fn default_health_timeout_ms() -> u64 {
5000
}
fn default_health_retries() -> u32 {
3
}
fn default_health_start_period_ms() -> u64 {
0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoggingDef {
#[serde(default = "default_buffer_lines")]
pub buffer_lines: usize,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub forward: Option<String>,
}
impl Default for LoggingDef {
fn default() -> Self {
Self {
buffer_lines: default_buffer_lines(),
file: None,
forward: None,
}
}
}
fn default_buffer_lines() -> usize {
1000
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TargetConfig {
pub target: TargetDef,
#[serde(default)]
pub dependencies: DependencyDef,
}
impl TargetConfig {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self, ConfigError> {
toml::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TargetDef {
pub name: String,
#[serde(default)]
pub critical: bool,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(String),
#[error("Parse error: {0}")]
Parse(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_service() {
let toml = r#"
[service]
name = "test"
exec = "/bin/test"
"#;
let config = ServiceConfig::parse(toml).unwrap();
assert_eq!(config.service.name, "test");
assert_eq!(config.service.exec, "/bin/test");
assert!(!config.service.oneshot);
assert!(config.dependencies.requires.is_empty());
assert_eq!(config.lifecycle.restart, RestartPolicy::OnFailure);
}
#[test]
fn test_parse_full_service() {
let toml = r#"
[service]
name = "web-server"
exec = "/usr/bin/nginx -g 'daemon off;'"
dir = "/var/www"
oneshot = false
[service.env]
PORT = "8080"
DEBUG = "true"
[dependencies]
requires = ["network-ready"]
after = ["logger"]
wants = ["metrics"]
conflicts = ["old-web-server"]
[lifecycle]
restart = "always"
restart_delay_ms = 3000
start_timeout_ms = 60000
stop_timeout_ms = 30000
stop_signal = "SIGQUIT"
[health]
type = "http"
target = "http://localhost:8080/health"
expect_status = 200
interval_ms = 5000
timeout_ms = 2000
retries = 5
start_period_ms = 10000
[logging]
buffer_lines = 500
file = "/var/log/web-server.log"
"#;
let config = ServiceConfig::parse(toml).unwrap();
assert_eq!(config.service.name, "web-server");
assert_eq!(config.service.dir, Some("/var/www".to_string()));
assert_eq!(config.service.env.get("PORT"), Some(&"8080".to_string()));
assert_eq!(config.dependencies.requires, vec!["network-ready"]);
assert_eq!(config.dependencies.after, vec!["logger"]);
assert_eq!(config.lifecycle.restart, RestartPolicy::Always);
assert_eq!(config.lifecycle.stop_signal, "SIGQUIT");
match &config.health {
Some(HealthDef::Http {
target,
expect_status,
common,
}) => {
assert_eq!(target, "http://localhost:8080/health");
assert_eq!(*expect_status, 200);
assert_eq!(common.retries, 5);
}
_ => panic!("Expected HTTP health check"),
}
assert_eq!(config.logging.buffer_lines, 500);
}
#[test]
fn test_parse_target() {
let toml = r#"
[target]
name = "multi-user"
[dependencies]
requires = ["network", "logging"]
"#;
let config = TargetConfig::parse(toml).unwrap();
assert_eq!(config.target.name, "multi-user");
assert_eq!(config.dependencies.requires, vec!["network", "logging"]);
}
#[test]
fn test_restart_policy_serialization() {
assert_eq!(
serde_json::to_string(&RestartPolicy::Always).unwrap(),
"\"always\""
);
assert_eq!(
serde_json::to_string(&RestartPolicy::OnFailure).unwrap(),
"\"on-failure\""
);
assert_eq!(
serde_json::to_string(&RestartPolicy::Never).unwrap(),
"\"never\""
);
}
#[test]
fn test_health_check_types() {
let tcp_toml = r#"
[service]
name = "tcp-test"
exec = "/bin/test"
[health]
type = "tcp"
target = "localhost:5432"
"#;
let config = ServiceConfig::parse(tcp_toml).unwrap();
assert!(matches!(config.health, Some(HealthDef::Tcp { .. })));
let exec_toml = r#"
[service]
name = "exec-test"
exec = "/bin/test"
[health]
type = "exec"
target = "/usr/bin/healthcheck.sh"
"#;
let config = ServiceConfig::parse(exec_toml).unwrap();
assert!(matches!(config.health, Some(HealthDef::Exec { .. })));
}
#[test]
fn test_roundtrip() {
let config = ServiceConfig {
service: ServiceDef {
name: "test".to_string(),
exec: "/bin/test".to_string(),
dir: None,
oneshot: false,
env: HashMap::new(),
status: Status::default(),
class: ServiceClass::default(),
critical: false,
ports: Vec::new(),
kill_others: false,
process_filters: Vec::new(),
},
dependencies: DependencyDef::default(),
lifecycle: LifecycleDef::default(),
health: None,
logging: LoggingDef::default(),
};
let toml = config.to_toml().unwrap();
let parsed = ServiceConfig::parse(&toml).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_status_enum() {
assert_eq!(serde_json::to_string(&Status::Start).unwrap(), "\"start\"");
assert_eq!(serde_json::to_string(&Status::Stop).unwrap(), "\"stop\"");
assert_eq!(
serde_json::to_string(&Status::Ignore).unwrap(),
"\"ignore\""
);
assert_eq!(
serde_json::from_str::<Status>("\"start\"").unwrap(),
Status::Start
);
assert_eq!(
serde_json::from_str::<Status>("\"stop\"").unwrap(),
Status::Stop
);
assert_eq!(
serde_json::from_str::<Status>("\"ignore\"").unwrap(),
Status::Ignore
);
assert_eq!(Status::default(), Status::Start);
assert!(Status::Start.should_autostart());
assert!(!Status::Stop.should_autostart());
assert!(!Status::Ignore.should_autostart());
assert!(!Status::Start.should_stop());
assert!(Status::Stop.should_stop());
assert!(!Status::Ignore.should_stop());
assert!(!Status::Start.is_manual());
assert!(!Status::Stop.is_manual());
assert!(Status::Ignore.is_manual());
}
#[test]
fn test_status_in_service_config() {
let toml_default = r#"
[service]
name = "test"
exec = "/bin/test"
"#;
let config = ServiceConfig::parse(toml_default).unwrap();
assert_eq!(config.service.status, Status::Start);
let toml_stop = r#"
[service]
name = "test"
exec = "/bin/test"
status = "stop"
"#;
let config = ServiceConfig::parse(toml_stop).unwrap();
assert_eq!(config.service.status, Status::Stop);
let toml_ignore = r#"
[service]
name = "test"
exec = "/bin/test"
status = "ignore"
"#;
let config = ServiceConfig::parse(toml_ignore).unwrap();
assert_eq!(config.service.status, Status::Ignore);
}
#[test]
fn test_service_class_enum() {
assert_eq!(
serde_json::to_string(&ServiceClass::User).unwrap(),
"\"user\""
);
assert_eq!(
serde_json::to_string(&ServiceClass::System).unwrap(),
"\"system\""
);
assert_eq!(
serde_json::from_str::<ServiceClass>("\"user\"").unwrap(),
ServiceClass::User
);
assert_eq!(
serde_json::from_str::<ServiceClass>("\"system\"").unwrap(),
ServiceClass::System
);
assert_eq!(ServiceClass::default(), ServiceClass::User);
assert!(!ServiceClass::User.is_system());
assert!(ServiceClass::System.is_system());
assert!(ServiceClass::User.is_user());
assert!(!ServiceClass::System.is_user());
}
#[test]
fn test_class_in_service_config() {
let toml_default = r#"
[service]
name = "test"
exec = "/bin/test"
"#;
let config = ServiceConfig::parse(toml_default).unwrap();
assert_eq!(config.service.class, ServiceClass::User);
let toml_user = r#"
[service]
name = "test"
exec = "/bin/test"
class = "user"
"#;
let config = ServiceConfig::parse(toml_user).unwrap();
assert_eq!(config.service.class, ServiceClass::User);
let toml_system = r#"
[service]
name = "test"
exec = "/bin/test"
class = "system"
"#;
let config = ServiceConfig::parse(toml_system).unwrap();
assert_eq!(config.service.class, ServiceClass::System);
}
}