opencode_cloud_core/platform/
systemd.rs

1//! systemd service manager for Linux
2//!
3//! This module provides SystemdManager which implements the ServiceManager trait
4//! for registering opencode-cloud as a systemd user service on Linux.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9
10use anyhow::{Result, anyhow};
11
12use super::{InstallResult, ServiceConfig, ServiceManager};
13
14/// Service name used for systemd unit
15const SERVICE_NAME: &str = "opencode-cloud";
16
17/// SystemdManager handles service registration with systemd on Linux
18#[derive(Debug, Clone)]
19pub struct SystemdManager {
20    /// true = user mode (~/.config/systemd/user/), false = system mode (/etc/systemd/system/)
21    user_mode: bool,
22}
23
24impl SystemdManager {
25    /// Create a new SystemdManager
26    ///
27    /// # Arguments
28    /// * `boot_mode` - "user" for user-level service (default), "system" for system-level
29    pub fn new(boot_mode: &str) -> Self {
30        Self {
31            user_mode: boot_mode != "system",
32        }
33    }
34
35    /// Get the directory where service files are stored
36    fn service_dir(&self) -> PathBuf {
37        if self.user_mode {
38            // User-level: ~/.config/systemd/user/
39            dirs::config_dir()
40                .unwrap_or_else(|| PathBuf::from("~/.config"))
41                .join("systemd")
42                .join("user")
43        } else {
44            // System-level: /etc/systemd/system/
45            PathBuf::from("/etc/systemd/system")
46        }
47    }
48
49    /// Generate the systemd unit file content
50    fn generate_unit_file(&self, config: &ServiceConfig) -> String {
51        let executable_path = config.executable_path.display().to_string();
52
53        // Quote path if it contains spaces
54        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        // Calculate StartLimitIntervalSec: restart_delay * restart_retries * 2
67        // This gives enough window for the allowed burst of restarts
68        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    /// Run systemctl with the appropriate mode flag
98    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    /// Run systemctl and check for success
109    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
124/// Check if systemd is available on this system
125///
126/// Returns true if /run/systemd/system exists, indicating systemd is running
127/// as the init system.
128pub 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        // Check permissions for system-level installation
135        if !self.user_mode {
136            // Check if we can write to /etc/systemd/system/
137            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        // 1. Create service directory if needed
148        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        // 2. Generate and write unit file
158        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        // 3. Reload systemd daemon to pick up the new unit file
170        self.systemctl_ok(&["daemon-reload"])?;
171
172        // 4. Enable the service for auto-start
173        self.systemctl_ok(&["enable", SERVICE_NAME])?;
174
175        // 5. Start the service
176        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        // 1. Stop the service (ignore error if not running)
188        let _ = self.systemctl(&["stop", SERVICE_NAME]);
189
190        // 2. Disable the service
191        let _ = self.systemctl(&["disable", SERVICE_NAME]);
192
193        // 3. Remove the unit file
194        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        // 4. Reload daemon to reflect the removal
206        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        // Any value other than "system" should default to user mode
243        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        // Should end with systemd/user
252        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        // Verify essential sections
288        assert!(unit.contains("[Unit]"));
289        assert!(unit.contains("[Service]"));
290        assert!(unit.contains("[Install]"));
291
292        // Verify key settings
293        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")); // 5 * 3 * 2
300        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        // Path should be quoted
316        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")); // 10 * 5 * 2
335    }
336
337    #[test]
338    fn test_is_installed_returns_false_for_nonexistent() {
339        let manager = SystemdManager::new("user");
340        // On a test system without the service installed, this should return false
341        // This test works because the service file won't exist in test environment
342        let result = manager.is_installed();
343        assert!(result.is_ok());
344        // Can't assert false because the service might actually be installed on some systems
345    }
346}