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