Skip to main content

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            directories::BaseDirs::new()
40                .map(|dirs| dirs.home_dir().join(".config"))
41                .unwrap_or_else(|| PathBuf::from("~/.config"))
42                .join("systemd")
43                .join("user")
44        } else {
45            // System-level: /etc/systemd/system/
46            PathBuf::from("/etc/systemd/system")
47        }
48    }
49
50    /// Generate the systemd unit file content
51    fn generate_unit_file(&self, config: &ServiceConfig) -> String {
52        let executable_path = config.executable_path.display().to_string();
53
54        // Quote path if it contains spaces
55        let exec_start = if executable_path.contains(' ') {
56            format!("\"{executable_path}\" start --no-daemon")
57        } else {
58            format!("{executable_path} start --no-daemon")
59        };
60
61        let exec_stop = if executable_path.contains(' ') {
62            format!("\"{executable_path}\" stop")
63        } else {
64            format!("{executable_path} stop")
65        };
66
67        // Calculate StartLimitIntervalSec: restart_delay * restart_retries * 2
68        // This gives enough window for the allowed burst of restarts
69        let start_limit_interval = config.restart_delay * config.restart_retries * 2;
70
71        format!(
72            r#"[Unit]
73Description=opencode-cloud container service
74Documentation=https://github.com/pRizz/opencode-cloud
75After=docker.service
76Requires=docker.service
77
78[Service]
79Type=simple
80ExecStart={exec_start}
81ExecStop={exec_stop}
82Restart=on-failure
83RestartSec={restart_delay}s
84StartLimitBurst={restart_retries}
85StartLimitIntervalSec={start_limit_interval}
86
87[Install]
88WantedBy=default.target
89"#,
90            exec_start = exec_start,
91            exec_stop = exec_stop,
92            restart_delay = config.restart_delay,
93            restart_retries = config.restart_retries,
94            start_limit_interval = start_limit_interval,
95        )
96    }
97
98    /// Run systemctl with the appropriate mode flag
99    fn systemctl(&self, args: &[&str]) -> Result<Output> {
100        let mut cmd = Command::new("systemctl");
101        if self.user_mode {
102            cmd.arg("--user");
103        }
104        cmd.args(args)
105            .output()
106            .map_err(|e| anyhow!("Failed to run systemctl: {e}"))
107    }
108
109    /// Run systemctl and check for success
110    fn systemctl_ok(&self, args: &[&str]) -> Result<()> {
111        let output = self.systemctl(args)?;
112        if output.status.success() {
113            Ok(())
114        } else {
115            let stderr = String::from_utf8_lossy(&output.stderr);
116            Err(anyhow!(
117                "systemctl {} failed: {}",
118                args.join(" "),
119                stderr.trim()
120            ))
121        }
122    }
123}
124
125/// Check if systemd is available on this system
126///
127/// Returns true if /run/systemd/system exists, indicating systemd is running
128/// as the init system.
129pub fn systemd_available() -> bool {
130    Path::new("/run/systemd/system").exists()
131}
132
133impl ServiceManager for SystemdManager {
134    fn install(&self, config: &ServiceConfig) -> Result<InstallResult> {
135        // Check permissions for system-level installation
136        if !self.user_mode {
137            // Check if we can write to /etc/systemd/system/
138            let test_path = self.service_dir().join(".opencode-cloud-test");
139            if fs::write(&test_path, "").is_err() {
140                return Err(anyhow!(
141                    "System-level installation requires root privileges. \
142                     Run with sudo or use user-level installation (default)."
143                ));
144            }
145            let _ = fs::remove_file(&test_path);
146        }
147
148        // 1. Create service directory if needed
149        let service_dir = self.service_dir();
150        fs::create_dir_all(&service_dir).map_err(|e| {
151            anyhow!(
152                "Failed to create service directory {}: {}",
153                service_dir.display(),
154                e
155            )
156        })?;
157
158        // 2. Generate and write unit file
159        let unit_content = self.generate_unit_file(config);
160        let service_file = self.service_file_path();
161
162        fs::write(&service_file, &unit_content).map_err(|e| {
163            anyhow!(
164                "Failed to write service file {}: {}",
165                service_file.display(),
166                e
167            )
168        })?;
169
170        // 3. Reload systemd daemon to pick up the new unit file
171        self.systemctl_ok(&["daemon-reload"])?;
172
173        // 4. Enable the service for auto-start
174        self.systemctl_ok(&["enable", SERVICE_NAME])?;
175
176        // 5. Start the service
177        let started = self.systemctl_ok(&["start", SERVICE_NAME]).is_ok();
178
179        Ok(InstallResult {
180            service_file_path: service_file,
181            service_name: SERVICE_NAME.to_string(),
182            started,
183            requires_root: !self.user_mode,
184        })
185    }
186
187    fn uninstall(&self) -> Result<()> {
188        // 1. Stop the service (ignore error if not running)
189        let _ = self.systemctl(&["stop", SERVICE_NAME]);
190
191        // 2. Disable the service
192        let _ = self.systemctl(&["disable", SERVICE_NAME]);
193
194        // 3. Remove the unit file
195        let service_file = self.service_file_path();
196        if service_file.exists() {
197            fs::remove_file(&service_file).map_err(|e| {
198                anyhow!(
199                    "Failed to remove service file {}: {}",
200                    service_file.display(),
201                    e
202                )
203            })?;
204        }
205
206        // 4. Reload daemon to reflect the removal
207        self.systemctl_ok(&["daemon-reload"])?;
208
209        Ok(())
210    }
211
212    fn is_installed(&self) -> Result<bool> {
213        Ok(self.service_file_path().exists())
214    }
215
216    fn service_file_path(&self) -> PathBuf {
217        self.service_dir().join(format!("{SERVICE_NAME}.service"))
218    }
219
220    fn service_name(&self) -> &str {
221        SERVICE_NAME
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_systemd_manager_new_user_mode() {
231        let manager = SystemdManager::new("user");
232        assert!(manager.user_mode);
233    }
234
235    #[test]
236    fn test_systemd_manager_new_system_mode() {
237        let manager = SystemdManager::new("system");
238        assert!(!manager.user_mode);
239    }
240
241    #[test]
242    fn test_systemd_manager_new_default_to_user() {
243        // Any value other than "system" should default to user mode
244        let manager = SystemdManager::new("login");
245        assert!(manager.user_mode);
246    }
247
248    #[test]
249    fn test_service_dir_user_mode() {
250        let manager = SystemdManager::new("user");
251        let dir = manager.service_dir();
252        // Should end with systemd/user
253        assert!(dir.ends_with("systemd/user"));
254    }
255
256    #[test]
257    fn test_service_dir_system_mode() {
258        let manager = SystemdManager::new("system");
259        let dir = manager.service_dir();
260        assert_eq!(dir, PathBuf::from("/etc/systemd/system"));
261    }
262
263    #[test]
264    fn test_service_file_path() {
265        let manager = SystemdManager::new("user");
266        let path = manager.service_file_path();
267        assert!(path.ends_with("opencode-cloud.service"));
268    }
269
270    #[test]
271    fn test_service_name() {
272        let manager = SystemdManager::new("user");
273        assert_eq!(manager.service_name(), "opencode-cloud");
274    }
275
276    #[test]
277    fn test_generate_unit_file_basic() {
278        let manager = SystemdManager::new("user");
279        let config = ServiceConfig {
280            executable_path: PathBuf::from("/usr/local/bin/occ"),
281            restart_retries: 3,
282            restart_delay: 5,
283            boot_mode: "user".to_string(),
284        };
285
286        let unit = manager.generate_unit_file(&config);
287
288        // Verify essential sections
289        assert!(unit.contains("[Unit]"));
290        assert!(unit.contains("[Service]"));
291        assert!(unit.contains("[Install]"));
292
293        // Verify key settings
294        assert!(unit.contains("Description=opencode-cloud container service"));
295        assert!(unit.contains("ExecStart=/usr/local/bin/occ start --no-daemon"));
296        assert!(unit.contains("ExecStop=/usr/local/bin/occ stop"));
297        assert!(unit.contains("Restart=on-failure"));
298        assert!(unit.contains("RestartSec=5s"));
299        assert!(unit.contains("StartLimitBurst=3"));
300        assert!(unit.contains("StartLimitIntervalSec=30")); // 5 * 3 * 2
301        assert!(unit.contains("WantedBy=default.target"));
302    }
303
304    #[test]
305    fn test_generate_unit_file_with_spaces_in_path() {
306        let manager = SystemdManager::new("user");
307        let config = ServiceConfig {
308            executable_path: PathBuf::from("/Users/test user/bin/occ"),
309            restart_retries: 3,
310            restart_delay: 5,
311            boot_mode: "user".to_string(),
312        };
313
314        let unit = manager.generate_unit_file(&config);
315
316        // Path should be quoted
317        assert!(unit.contains("ExecStart=\"/Users/test user/bin/occ\" start --no-daemon"));
318        assert!(unit.contains("ExecStop=\"/Users/test user/bin/occ\" stop"));
319    }
320
321    #[test]
322    fn test_generate_unit_file_custom_restart_policy() {
323        let manager = SystemdManager::new("user");
324        let config = ServiceConfig {
325            executable_path: PathBuf::from("/usr/bin/occ"),
326            restart_retries: 5,
327            restart_delay: 10,
328            boot_mode: "user".to_string(),
329        };
330
331        let unit = manager.generate_unit_file(&config);
332
333        assert!(unit.contains("RestartSec=10s"));
334        assert!(unit.contains("StartLimitBurst=5"));
335        assert!(unit.contains("StartLimitIntervalSec=100")); // 10 * 5 * 2
336    }
337
338    #[test]
339    fn test_is_installed_returns_false_for_nonexistent() {
340        let manager = SystemdManager::new("user");
341        // On a test system without the service installed, this should return false
342        // This test works because the service file won't exist in test environment
343        let result = manager.is_installed();
344        assert!(result.is_ok());
345        // Can't assert false because the service might actually be installed on some systems
346    }
347}