@opencode-cloud/core 0.1.3 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
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
+
6
+ use std::fs;
7
+ use std::path::{Path, PathBuf};
8
+ use std::process::{Command, Output};
9
+
10
+ use anyhow::{Result, anyhow};
11
+
12
+ use super::{InstallResult, ServiceConfig, ServiceManager};
13
+
14
+ /// Service name used for systemd unit
15
+ const SERVICE_NAME: &str = "opencode-cloud";
16
+
17
+ /// SystemdManager handles service registration with systemd on Linux
18
+ #[derive(Debug, Clone)]
19
+ pub struct SystemdManager {
20
+ /// true = user mode (~/.config/systemd/user/), false = system mode (/etc/systemd/system/)
21
+ user_mode: bool,
22
+ }
23
+
24
+ impl 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]
72
+ Description=opencode-cloud container service
73
+ Documentation=https://github.com/pRizz/opencode-cloud
74
+ After=docker.service
75
+ Requires=docker.service
76
+
77
+ [Service]
78
+ Type=simple
79
+ ExecStart={exec_start}
80
+ ExecStop={exec_stop}
81
+ Restart=on-failure
82
+ RestartSec={restart_delay}s
83
+ StartLimitBurst={restart_retries}
84
+ StartLimitIntervalSec={start_limit_interval}
85
+
86
+ [Install]
87
+ WantedBy=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.
128
+ pub fn systemd_available() -> bool {
129
+ Path::new("/run/systemd/system").exists()
130
+ }
131
+
132
+ impl 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)]
225
+ mod 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
+ }