consortium_builder/compile/deploy/
systemd.rs1use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum SystemdRestartPolicy {
30 No,
32 #[default]
34 OnFailure,
35 Always,
37}
38
39impl SystemdRestartPolicy {
40 fn as_str(self) -> &'static str {
42 match self {
43 Self::No => "no",
44 Self::OnFailure => "on-failure",
45 Self::Always => "always",
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct SystemdUnitConfig {
53 pub unit_name: String,
55 pub exec_start: String,
57 pub working_directory: String,
59 pub description: Option<String>,
61 pub restart: SystemdRestartPolicy,
63}
64
65impl Default for SystemdUnitConfig {
66 fn default() -> Self {
67 Self {
68 unit_name: "consortium-app".to_string(),
69 exec_start: String::new(),
70 working_directory: String::new(),
71 description: None,
72 restart: SystemdRestartPolicy::default(),
73 }
74 }
75}
76
77impl SystemdUnitConfig {
78 pub fn file_name(&self) -> String {
80 format!("{}.service", self.unit_name)
81 }
82}
83
84pub fn render_unit(config: &SystemdUnitConfig) -> String {
86 let description = config
87 .description
88 .clone()
89 .unwrap_or_else(|| "Consortium application daemon".to_string());
90
91 let mut ini = ini::Ini::new();
92 ini.with_section(Some("Unit"))
93 .set("Description", description)
94 .set("After", "network.target");
95 ini.with_section(Some("Service"))
96 .set("Type", "simple")
97 .set("ExecStart", &config.exec_start)
98 .set("WorkingDirectory", &config.working_directory)
99 .set("Restart", config.restart.as_str());
100 ini.with_section(Some("Install"))
101 .set("WantedBy", "multi-user.target");
102
103 let opts = ini::WriteOption {
105 kv_separator: "=",
106 ..Default::default()
107 };
108 let mut buf = Vec::new();
109 ini.write_to_opt(&mut buf, opts)
110 .expect("writing an in-memory INI cannot fail");
111 String::from_utf8(buf).expect("systemd unit fields are UTF-8")
112}
113
114pub async fn write_unit(dir: &Path, config: &SystemdUnitConfig) -> std::io::Result<PathBuf> {
117 tokio::fs::create_dir_all(dir).await?;
118 let path = dir.join(config.file_name());
119 tokio::fs::write(&path, render_unit(config)).await?;
120 Ok(path)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn config() -> SystemdUnitConfig {
128 SystemdUnitConfig {
129 unit_name: "consortium-app".to_string(),
130 exec_start: "/opt/consortium/app/melt-pot-app".to_string(),
131 working_directory: "/opt/consortium/app".to_string(),
132 description: None,
133 restart: SystemdRestartPolicy::OnFailure,
134 }
135 }
136
137 #[test]
138 fn renders_exact_unit_text() {
139 let expected = "\
140[Unit]
141Description=Consortium application daemon
142After=network.target
143
144[Service]
145Type=simple
146ExecStart=/opt/consortium/app/melt-pot-app
147WorkingDirectory=/opt/consortium/app
148Restart=on-failure
149
150[Install]
151WantedBy=multi-user.target
152";
153 assert_eq!(render_unit(&config()), expected);
154 }
155
156 #[test]
157 fn honours_description_and_restart_overrides() {
158 let mut cfg = config();
159 cfg.description = Some("Melt-pot HMI".to_string());
160 cfg.restart = SystemdRestartPolicy::Always;
161 let out = render_unit(&cfg);
162 assert!(out.contains("Description=Melt-pot HMI\n"));
163 assert!(out.contains("Restart=always\n"));
164 }
165
166 #[tokio::test]
167 async fn write_unit_writes_named_file() {
168 let dir = std::env::temp_dir().join(format!("consortium-systemd-{}", std::process::id()));
169 let _ = std::fs::remove_dir_all(&dir);
170 let path = write_unit(&dir, &config()).await.expect("write unit");
171 assert_eq!(path, dir.join("consortium-app.service"));
172 let text = std::fs::read_to_string(&path).expect("read unit");
173 assert_eq!(text, render_unit(&config()));
174 let _ = std::fs::remove_dir_all(&dir);
175 }
176}