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 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 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 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
125pub fn systemd_available() -> bool {
130 Path::new("/run/systemd/system").exists()
131}
132
133pub fn systemd_user_session_available() -> bool {
138 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
139 Path::new(&runtime_dir).join("systemd").exists()
141 } else {
142 false
143 }
144}
145
146impl ServiceManager for SystemdManager {
147 fn install(&self, config: &ServiceConfig) -> Result<InstallResult> {
148 if self.user_mode {
150 if !systemd_user_session_available() {
152 return Err(anyhow!(
153 "User-level systemd session not available.\n\
154 This typically happens during cloud-init or when running as a \
155 different user without an active login session.\n\n\
156 Solutions:\n\
157 1. Use system-level installation: occ config set boot_mode system\n\
158 2. Run the command from an interactive login session\n\
159 3. Ensure XDG_RUNTIME_DIR is set and the user has an active systemd session"
160 ));
161 }
162 } else {
163 let test_path = self.service_dir().join(".opencode-cloud-test");
165 if fs::write(&test_path, "").is_err() {
166 return Err(anyhow!(
167 "System-level installation requires root privileges. \
168 Run with sudo or use user-level installation (default)."
169 ));
170 }
171 let _ = fs::remove_file(&test_path);
172 }
173
174 let service_dir = self.service_dir();
176 fs::create_dir_all(&service_dir).map_err(|e| {
177 anyhow!(
178 "Failed to create service directory {}: {}",
179 service_dir.display(),
180 e
181 )
182 })?;
183
184 let unit_content = self.generate_unit_file(config);
186 let service_file = self.service_file_path();
187
188 fs::write(&service_file, &unit_content).map_err(|e| {
189 anyhow!(
190 "Failed to write service file {}: {}",
191 service_file.display(),
192 e
193 )
194 })?;
195
196 self.systemctl_ok(&["daemon-reload"])?;
198
199 self.systemctl_ok(&["enable", SERVICE_NAME])?;
201
202 let started = self.systemctl_ok(&["start", SERVICE_NAME]).is_ok();
204
205 Ok(InstallResult {
206 service_file_path: service_file,
207 service_name: SERVICE_NAME.to_string(),
208 started,
209 requires_root: !self.user_mode,
210 })
211 }
212
213 fn uninstall(&self) -> Result<()> {
214 let _ = self.systemctl(&["stop", SERVICE_NAME]);
216
217 let _ = self.systemctl(&["disable", SERVICE_NAME]);
219
220 let service_file = self.service_file_path();
222 if service_file.exists() {
223 fs::remove_file(&service_file).map_err(|e| {
224 anyhow!(
225 "Failed to remove service file {}: {}",
226 service_file.display(),
227 e
228 )
229 })?;
230 }
231
232 self.systemctl_ok(&["daemon-reload"])?;
234
235 Ok(())
236 }
237
238 fn is_installed(&self) -> Result<bool> {
239 Ok(self.service_file_path().exists())
240 }
241
242 fn service_file_path(&self) -> PathBuf {
243 self.service_dir().join(format!("{SERVICE_NAME}.service"))
244 }
245
246 fn service_name(&self) -> &str {
247 SERVICE_NAME
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_systemd_manager_new_user_mode() {
257 let manager = SystemdManager::new("user");
258 assert!(manager.user_mode);
259 }
260
261 #[test]
262 fn test_systemd_manager_new_system_mode() {
263 let manager = SystemdManager::new("system");
264 assert!(!manager.user_mode);
265 }
266
267 #[test]
268 fn test_systemd_manager_new_default_to_user() {
269 let manager = SystemdManager::new("login");
271 assert!(manager.user_mode);
272 }
273
274 #[test]
275 fn test_service_dir_user_mode() {
276 let manager = SystemdManager::new("user");
277 let dir = manager.service_dir();
278 assert!(dir.ends_with("systemd/user"));
280 }
281
282 #[test]
283 fn test_service_dir_system_mode() {
284 let manager = SystemdManager::new("system");
285 let dir = manager.service_dir();
286 assert_eq!(dir, PathBuf::from("/etc/systemd/system"));
287 }
288
289 #[test]
290 fn test_service_file_path() {
291 let manager = SystemdManager::new("user");
292 let path = manager.service_file_path();
293 assert!(path.ends_with("opencode-cloud.service"));
294 }
295
296 #[test]
297 fn test_service_name() {
298 let manager = SystemdManager::new("user");
299 assert_eq!(manager.service_name(), "opencode-cloud");
300 }
301
302 #[test]
303 fn test_generate_unit_file_basic() {
304 let manager = SystemdManager::new("user");
305 let config = ServiceConfig {
306 executable_path: PathBuf::from("/usr/local/bin/occ"),
307 restart_retries: 3,
308 restart_delay: 5,
309 boot_mode: "user".to_string(),
310 };
311
312 let unit = manager.generate_unit_file(&config);
313
314 assert!(unit.contains("[Unit]"));
316 assert!(unit.contains("[Service]"));
317 assert!(unit.contains("[Install]"));
318
319 assert!(unit.contains("Description=opencode-cloud container service"));
321 assert!(unit.contains("ExecStart=/usr/local/bin/occ start --no-daemon"));
322 assert!(unit.contains("ExecStop=/usr/local/bin/occ stop"));
323 assert!(unit.contains("Restart=on-failure"));
324 assert!(unit.contains("RestartSec=5s"));
325 assert!(unit.contains("StartLimitBurst=3"));
326 assert!(unit.contains("StartLimitIntervalSec=30")); assert!(unit.contains("WantedBy=default.target"));
328 }
329
330 #[test]
331 fn test_generate_unit_file_with_spaces_in_path() {
332 let manager = SystemdManager::new("user");
333 let config = ServiceConfig {
334 executable_path: PathBuf::from("/Users/test user/bin/occ"),
335 restart_retries: 3,
336 restart_delay: 5,
337 boot_mode: "user".to_string(),
338 };
339
340 let unit = manager.generate_unit_file(&config);
341
342 assert!(unit.contains("ExecStart=\"/Users/test user/bin/occ\" start --no-daemon"));
344 assert!(unit.contains("ExecStop=\"/Users/test user/bin/occ\" stop"));
345 }
346
347 #[test]
348 fn test_generate_unit_file_custom_restart_policy() {
349 let manager = SystemdManager::new("user");
350 let config = ServiceConfig {
351 executable_path: PathBuf::from("/usr/bin/occ"),
352 restart_retries: 5,
353 restart_delay: 10,
354 boot_mode: "user".to_string(),
355 };
356
357 let unit = manager.generate_unit_file(&config);
358
359 assert!(unit.contains("RestartSec=10s"));
360 assert!(unit.contains("StartLimitBurst=5"));
361 assert!(unit.contains("StartLimitIntervalSec=100")); }
363
364 #[test]
365 fn test_is_installed_returns_false_for_nonexistent() {
366 let manager = SystemdManager::new("user");
367 let result = manager.is_installed();
370 assert!(result.is_ok());
371 }
373}