use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SystemdRestartPolicy {
No,
#[default]
OnFailure,
Always,
}
impl SystemdRestartPolicy {
fn as_str(self) -> &'static str {
match self {
Self::No => "no",
Self::OnFailure => "on-failure",
Self::Always => "always",
}
}
}
#[derive(Debug, Clone)]
pub struct SystemdUnitConfig {
pub unit_name: String,
pub exec_start: String,
pub working_directory: String,
pub description: Option<String>,
pub restart: SystemdRestartPolicy,
}
impl Default for SystemdUnitConfig {
fn default() -> Self {
Self {
unit_name: "consortium-app".to_string(),
exec_start: String::new(),
working_directory: String::new(),
description: None,
restart: SystemdRestartPolicy::default(),
}
}
}
impl SystemdUnitConfig {
pub fn file_name(&self) -> String {
format!("{}.service", self.unit_name)
}
}
pub fn render_unit(config: &SystemdUnitConfig) -> String {
let description = config
.description
.clone()
.unwrap_or_else(|| "Consortium application daemon".to_string());
let mut ini = ini::Ini::new();
ini.with_section(Some("Unit"))
.set("Description", description)
.set("After", "network.target");
ini.with_section(Some("Service"))
.set("Type", "simple")
.set("ExecStart", &config.exec_start)
.set("WorkingDirectory", &config.working_directory)
.set("Restart", config.restart.as_str());
ini.with_section(Some("Install"))
.set("WantedBy", "multi-user.target");
let opts = ini::WriteOption {
kv_separator: "=",
..Default::default()
};
let mut buf = Vec::new();
ini.write_to_opt(&mut buf, opts)
.expect("writing an in-memory INI cannot fail");
String::from_utf8(buf).expect("systemd unit fields are UTF-8")
}
pub async fn write_unit(dir: &Path, config: &SystemdUnitConfig) -> std::io::Result<PathBuf> {
tokio::fs::create_dir_all(dir).await?;
let path = dir.join(config.file_name());
tokio::fs::write(&path, render_unit(config)).await?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> SystemdUnitConfig {
SystemdUnitConfig {
unit_name: "consortium-app".to_string(),
exec_start: "/opt/consortium/app/melt-pot-app".to_string(),
working_directory: "/opt/consortium/app".to_string(),
description: None,
restart: SystemdRestartPolicy::OnFailure,
}
}
#[test]
fn renders_exact_unit_text() {
let expected = "\
[Unit]
Description=Consortium application daemon
After=network.target
[Service]
Type=simple
ExecStart=/opt/consortium/app/melt-pot-app
WorkingDirectory=/opt/consortium/app
Restart=on-failure
[Install]
WantedBy=multi-user.target
";
assert_eq!(render_unit(&config()), expected);
}
#[test]
fn honours_description_and_restart_overrides() {
let mut cfg = config();
cfg.description = Some("Melt-pot HMI".to_string());
cfg.restart = SystemdRestartPolicy::Always;
let out = render_unit(&cfg);
assert!(out.contains("Description=Melt-pot HMI\n"));
assert!(out.contains("Restart=always\n"));
}
#[tokio::test]
async fn write_unit_writes_named_file() {
let dir = std::env::temp_dir().join(format!("consortium-systemd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = write_unit(&dir, &config()).await.expect("write unit");
assert_eq!(path, dir.join("consortium-app.service"));
let text = std::fs::read_to_string(&path).expect("read unit");
assert_eq!(text, render_unit(&config()));
let _ = std::fs::remove_dir_all(&dir);
}
}