opencode_cloud_core/platform/
systemd.rs1use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9
10use anyhow::{Result, anyhow};
11
12use super::{InstallResult, ServiceConfig, ServiceManager};
13
14const SERVICE_NAME: &str = "opencode-cloud";
16
17#[derive(Debug, Clone)]
19pub struct SystemdManager {
20 user_mode: bool,
22}
23
24impl SystemdManager {
25 pub fn new(boot_mode: &str) -> Self {
30 Self {
31 user_mode: boot_mode != "system",
32 }
33 }
34
35 fn service_dir(&self) -> PathBuf {
37 if self.user_mode {
38 dirs::config_dir()
40 .unwrap_or_else(|| PathBuf::from("~/.config"))
41 .join("systemd")
42 .join("user")
43 } else {
44 PathBuf::from("/etc/systemd/system")
46 }
47 }
48
49 fn generate_unit_file(&self, config: &ServiceConfig) -> String {
51 let executable_path = config.executable_path.display().to_string();
52
53 let exec_start = if executable_path.contains(' ') {
55 format!("\"{}\" start --no-daemon", executable_path)
56 } else {
57 format!("{} start --no-daemon", executable_path)
58 };
59
60 let exec_stop = if executable_path.contains(' ') {
61 format!("\"{}\" stop", executable_path)
62 } else {
63 format!("{} stop", executable_path)
64 };
65
66 let start_limit_interval = config.restart_delay * config.restart_retries * 2;
69
70 format!(
71 r#"[Unit]
72Description=opencode-cloud container service
73Documentation=https://github.com/pRizz/opencode-cloud
74After=docker.service
75Requires=docker.service
76
77[Service]
78Type=simple
79ExecStart={exec_start}
80ExecStop={exec_stop}
81Restart=on-failure
82RestartSec={restart_delay}s
83StartLimitBurst={restart_retries}
84StartLimitIntervalSec={start_limit_interval}
85
86[Install]
87WantedBy=default.target
88"#,
89 exec_start = exec_start,
90 exec_stop = exec_stop,
91 restart_delay = config.restart_delay,
92 restart_retries = config.restart_retries,
93 start_limit_interval = start_limit_interval,
94 )
95 }
96
97 fn systemctl(&self, args: &[&str]) -> Result<Output> {
99 let mut cmd = Command::new("systemctl");
100 if self.user_mode {
101 cmd.arg("--user");
102 }
103 cmd.args(args)
104 .output()
105 .map_err(|e| anyhow!("Failed to run systemctl: {}", e))
106 }
107
108 fn systemctl_ok(&self, args: &[&str]) -> Result<()> {
110 let output = self.systemctl(args)?;
111 if output.status.success() {
112 Ok(())
113 } else {
114 let stderr = String::from_utf8_lossy(&output.stderr);
115 Err(anyhow!(
116 "systemctl {} failed: {}",
117 args.join(" "),
118 stderr.trim()
119 ))
120 }
121 }
122}
123
124pub fn systemd_available() -> bool {
129 Path::new("/run/systemd/system").exists()
130}
131
132impl ServiceManager for SystemdManager {
133 fn install(&self, config: &ServiceConfig) -> Result<InstallResult> {
134 if !self.user_mode {
136 let test_path = self.service_dir().join(".opencode-cloud-test");
138 if fs::write(&test_path, "").is_err() {
139 return Err(anyhow!(
140 "System-level installation requires root privileges. \
141 Run with sudo or use user-level installation (default)."
142 ));
143 }
144 let _ = fs::remove_file(&test_path);
145 }
146
147 let service_dir = self.service_dir();
149 fs::create_dir_all(&service_dir).map_err(|e| {
150 anyhow!(
151 "Failed to create service directory {}: {}",
152 service_dir.display(),
153 e
154 )
155 })?;
156
157 let unit_content = self.generate_unit_file(config);
159 let service_file = self.service_file_path();
160
161 fs::write(&service_file, &unit_content).map_err(|e| {
162 anyhow!(
163 "Failed to write service file {}: {}",
164 service_file.display(),
165 e
166 )
167 })?;
168
169 self.systemctl_ok(&["daemon-reload"])?;
171
172 self.systemctl_ok(&["enable", SERVICE_NAME])?;
174
175 let started = self.systemctl_ok(&["start", SERVICE_NAME]).is_ok();
177
178 Ok(InstallResult {
179 service_file_path: service_file,
180 service_name: SERVICE_NAME.to_string(),
181 started,
182 requires_root: !self.user_mode,
183 })
184 }
185
186 fn uninstall(&self) -> Result<()> {
187 let _ = self.systemctl(&["stop", SERVICE_NAME]);
189
190 let _ = self.systemctl(&["disable", SERVICE_NAME]);
192
193 let service_file = self.service_file_path();
195 if service_file.exists() {
196 fs::remove_file(&service_file).map_err(|e| {
197 anyhow!(
198 "Failed to remove service file {}: {}",
199 service_file.display(),
200 e
201 )
202 })?;
203 }
204
205 self.systemctl_ok(&["daemon-reload"])?;
207
208 Ok(())
209 }
210
211 fn is_installed(&self) -> Result<bool> {
212 Ok(self.service_file_path().exists())
213 }
214
215 fn service_file_path(&self) -> PathBuf {
216 self.service_dir().join(format!("{}.service", SERVICE_NAME))
217 }
218
219 fn service_name(&self) -> &str {
220 SERVICE_NAME
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_systemd_manager_new_user_mode() {
230 let manager = SystemdManager::new("user");
231 assert!(manager.user_mode);
232 }
233
234 #[test]
235 fn test_systemd_manager_new_system_mode() {
236 let manager = SystemdManager::new("system");
237 assert!(!manager.user_mode);
238 }
239
240 #[test]
241 fn test_systemd_manager_new_default_to_user() {
242 let manager = SystemdManager::new("login");
244 assert!(manager.user_mode);
245 }
246
247 #[test]
248 fn test_service_dir_user_mode() {
249 let manager = SystemdManager::new("user");
250 let dir = manager.service_dir();
251 assert!(dir.ends_with("systemd/user"));
253 }
254
255 #[test]
256 fn test_service_dir_system_mode() {
257 let manager = SystemdManager::new("system");
258 let dir = manager.service_dir();
259 assert_eq!(dir, PathBuf::from("/etc/systemd/system"));
260 }
261
262 #[test]
263 fn test_service_file_path() {
264 let manager = SystemdManager::new("user");
265 let path = manager.service_file_path();
266 assert!(path.ends_with("opencode-cloud.service"));
267 }
268
269 #[test]
270 fn test_service_name() {
271 let manager = SystemdManager::new("user");
272 assert_eq!(manager.service_name(), "opencode-cloud");
273 }
274
275 #[test]
276 fn test_generate_unit_file_basic() {
277 let manager = SystemdManager::new("user");
278 let config = ServiceConfig {
279 executable_path: PathBuf::from("/usr/local/bin/occ"),
280 restart_retries: 3,
281 restart_delay: 5,
282 boot_mode: "user".to_string(),
283 };
284
285 let unit = manager.generate_unit_file(&config);
286
287 assert!(unit.contains("[Unit]"));
289 assert!(unit.contains("[Service]"));
290 assert!(unit.contains("[Install]"));
291
292 assert!(unit.contains("Description=opencode-cloud container service"));
294 assert!(unit.contains("ExecStart=/usr/local/bin/occ start --no-daemon"));
295 assert!(unit.contains("ExecStop=/usr/local/bin/occ stop"));
296 assert!(unit.contains("Restart=on-failure"));
297 assert!(unit.contains("RestartSec=5s"));
298 assert!(unit.contains("StartLimitBurst=3"));
299 assert!(unit.contains("StartLimitIntervalSec=30")); assert!(unit.contains("WantedBy=default.target"));
301 }
302
303 #[test]
304 fn test_generate_unit_file_with_spaces_in_path() {
305 let manager = SystemdManager::new("user");
306 let config = ServiceConfig {
307 executable_path: PathBuf::from("/Users/test user/bin/occ"),
308 restart_retries: 3,
309 restart_delay: 5,
310 boot_mode: "user".to_string(),
311 };
312
313 let unit = manager.generate_unit_file(&config);
314
315 assert!(unit.contains("ExecStart=\"/Users/test user/bin/occ\" start --no-daemon"));
317 assert!(unit.contains("ExecStop=\"/Users/test user/bin/occ\" stop"));
318 }
319
320 #[test]
321 fn test_generate_unit_file_custom_restart_policy() {
322 let manager = SystemdManager::new("user");
323 let config = ServiceConfig {
324 executable_path: PathBuf::from("/usr/bin/occ"),
325 restart_retries: 5,
326 restart_delay: 10,
327 boot_mode: "user".to_string(),
328 };
329
330 let unit = manager.generate_unit_file(&config);
331
332 assert!(unit.contains("RestartSec=10s"));
333 assert!(unit.contains("StartLimitBurst=5"));
334 assert!(unit.contains("StartLimitIntervalSec=100")); }
336
337 #[test]
338 fn test_is_installed_returns_false_for_nonexistent() {
339 let manager = SystemdManager::new("user");
340 let result = manager.is_installed();
343 assert!(result.is_ok());
344 }
346}