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 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 PathBuf::from("/etc/systemd/system")
47 }
48 }
49
50 fn generate_unit_file(&self, config: &ServiceConfig) -> String {
52 let executable_path = config.executable_path.display().to_string();
53
54 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 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 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 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
137pub fn systemd_available() -> bool {
142 Path::new("/run/systemd/system").exists()
143}
144
145pub fn systemd_user_session_available() -> bool {
150 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
151 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 if self.user_mode {
162 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 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 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 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 self.systemctl_ok(&["daemon-reload"])?;
210
211 self.systemctl_ok(&["enable", SERVICE_NAME])?;
213
214 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 let _ = self.systemctl(&["stop", SERVICE_NAME]);
228
229 let _ = self.systemctl(&["disable", SERVICE_NAME]);
231
232 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 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 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 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 assert!(unit.contains("[Unit]"));
328 assert!(unit.contains("[Service]"));
329 assert!(unit.contains("[Install]"));
330
331 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")); 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 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")); }
375
376 #[test]
377 fn test_is_installed_returns_false_for_nonexistent() {
378 let manager = SystemdManager::new("user");
379 let result = manager.is_installed();
382 assert!(result.is_ok());
383 }
385}