@opencode-cloud/core 0.1.3 → 1.0.4
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.
- package/Cargo.toml +13 -0
- package/README.md +172 -0
- package/core.darwin-arm64.node +0 -0
- package/package.json +1 -1
- package/src/config/schema.rs +62 -8
- package/src/docker/Dockerfile +444 -0
- package/src/docker/client.rs +84 -0
- package/src/docker/container.rs +317 -0
- package/src/docker/dockerfile.rs +41 -0
- package/src/docker/error.rs +79 -0
- package/src/docker/image.rs +502 -0
- package/src/docker/mod.rs +112 -0
- package/src/docker/progress.rs +401 -0
- package/src/docker/volume.rs +144 -0
- package/src/lib.rs +14 -0
- package/src/platform/launchd.rs +363 -0
- package/src/platform/mod.rs +191 -0
- package/src/platform/systemd.rs +347 -0
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
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!("\"{}\" start --no-daemon", executable_path)
|
|
57
|
+
} else {
|
|
58
|
+
format!("{} start --no-daemon", executable_path)
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let exec_stop = if executable_path.contains(' ') {
|
|
62
|
+
format!("\"{}\" stop", executable_path)
|
|
63
|
+
} else {
|
|
64
|
+
format!("{} stop", executable_path)
|
|
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]
|
|
73
|
+
Description=opencode-cloud container service
|
|
74
|
+
Documentation=https://github.com/pRizz/opencode-cloud
|
|
75
|
+
After=docker.service
|
|
76
|
+
Requires=docker.service
|
|
77
|
+
|
|
78
|
+
[Service]
|
|
79
|
+
Type=simple
|
|
80
|
+
ExecStart={exec_start}
|
|
81
|
+
ExecStop={exec_stop}
|
|
82
|
+
Restart=on-failure
|
|
83
|
+
RestartSec={restart_delay}s
|
|
84
|
+
StartLimitBurst={restart_retries}
|
|
85
|
+
StartLimitIntervalSec={start_limit_interval}
|
|
86
|
+
|
|
87
|
+
[Install]
|
|
88
|
+
WantedBy=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.
|
|
129
|
+
pub fn systemd_available() -> bool {
|
|
130
|
+
Path::new("/run/systemd/system").exists()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
impl 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", SERVICE_NAME))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn service_name(&self) -> &str {
|
|
221
|
+
SERVICE_NAME
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[cfg(test)]
|
|
226
|
+
mod 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
|
+
}
|