@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,401 @@
|
|
|
1
|
+
//! Progress reporting utilities for Docker operations
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides progress bars and spinners for Docker image
|
|
4
|
+
//! builds and pulls, using indicatif for terminal output.
|
|
5
|
+
|
|
6
|
+
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|
7
|
+
use std::collections::HashMap;
|
|
8
|
+
use std::time::{Duration, Instant};
|
|
9
|
+
|
|
10
|
+
/// Minimum time between spinner message updates to prevent flickering
|
|
11
|
+
const SPINNER_UPDATE_THROTTLE: Duration = Duration::from_millis(150);
|
|
12
|
+
|
|
13
|
+
/// Strip ANSI escape codes from a string
|
|
14
|
+
///
|
|
15
|
+
/// Docker build output often contains ANSI color codes that can interfere
|
|
16
|
+
/// with our spinner display. This removes them for clean output.
|
|
17
|
+
fn strip_ansi_codes(s: &str) -> String {
|
|
18
|
+
let mut result = String::with_capacity(s.len());
|
|
19
|
+
let mut chars = s.chars().peekable();
|
|
20
|
+
|
|
21
|
+
while let Some(c) = chars.next() {
|
|
22
|
+
if c == '\x1b' {
|
|
23
|
+
// Check for CSI sequence: ESC [
|
|
24
|
+
if chars.peek() == Some(&'[') {
|
|
25
|
+
chars.next(); // consume '['
|
|
26
|
+
// Skip until we hit a letter (the command character)
|
|
27
|
+
while let Some(&next) = chars.peek() {
|
|
28
|
+
chars.next();
|
|
29
|
+
if next.is_ascii_alphabetic() {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Also handle ESC followed by other sequences (less common)
|
|
35
|
+
} else {
|
|
36
|
+
result.push(c);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Format duration as MM:SS, or HH:MM:SS if over an hour
|
|
44
|
+
fn format_elapsed(duration: Duration) -> String {
|
|
45
|
+
let total_secs = duration.as_secs();
|
|
46
|
+
let hours = total_secs / 3600;
|
|
47
|
+
let minutes = (total_secs % 3600) / 60;
|
|
48
|
+
let seconds = total_secs % 60;
|
|
49
|
+
|
|
50
|
+
if hours > 0 {
|
|
51
|
+
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
|
52
|
+
} else {
|
|
53
|
+
format!("{:02}:{:02}", minutes, seconds)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Progress reporter for Docker operations
|
|
58
|
+
///
|
|
59
|
+
/// Manages multiple progress bars for concurrent operations like
|
|
60
|
+
/// multi-layer image pulls and build steps.
|
|
61
|
+
pub struct ProgressReporter {
|
|
62
|
+
multi: MultiProgress,
|
|
63
|
+
bars: HashMap<String, ProgressBar>,
|
|
64
|
+
last_update: HashMap<String, Instant>,
|
|
65
|
+
last_message: HashMap<String, String>,
|
|
66
|
+
start_time: Instant,
|
|
67
|
+
/// Optional context prefix shown before step messages (e.g., "Building Docker image")
|
|
68
|
+
context: Option<String>,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
impl Default for ProgressReporter {
|
|
72
|
+
fn default() -> Self {
|
|
73
|
+
Self::new()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
impl ProgressReporter {
|
|
78
|
+
/// Create a new progress reporter
|
|
79
|
+
pub fn new() -> Self {
|
|
80
|
+
Self {
|
|
81
|
+
multi: MultiProgress::new(),
|
|
82
|
+
bars: HashMap::new(),
|
|
83
|
+
last_update: HashMap::new(),
|
|
84
|
+
last_message: HashMap::new(),
|
|
85
|
+
start_time: Instant::now(),
|
|
86
|
+
context: None,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Create a new progress reporter with a context prefix
|
|
91
|
+
///
|
|
92
|
+
/// The context is shown before step messages, e.g., "Building Docker image · Step 1/10"
|
|
93
|
+
pub fn with_context(context: &str) -> Self {
|
|
94
|
+
Self {
|
|
95
|
+
multi: MultiProgress::new(),
|
|
96
|
+
bars: HashMap::new(),
|
|
97
|
+
last_update: HashMap::new(),
|
|
98
|
+
last_message: HashMap::new(),
|
|
99
|
+
start_time: Instant::now(),
|
|
100
|
+
context: Some(context.to_string()),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Format a message with context prefix if set
|
|
105
|
+
fn format_message(&self, message: &str) -> String {
|
|
106
|
+
let elapsed = format_elapsed(self.start_time.elapsed());
|
|
107
|
+
|
|
108
|
+
// Strip ANSI escape codes that Docker may include in its output
|
|
109
|
+
let stripped = strip_ansi_codes(message);
|
|
110
|
+
|
|
111
|
+
// Collapse message to single line for spinner display:
|
|
112
|
+
// - Replace all whitespace sequences (including newlines) with single space
|
|
113
|
+
// - Trim leading/trailing whitespace
|
|
114
|
+
let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
115
|
+
|
|
116
|
+
// Format: "[elapsed] Context · message" or "[elapsed] message"
|
|
117
|
+
// Timer at the beginning for easy scanning
|
|
118
|
+
match &self.context {
|
|
119
|
+
Some(ctx) => format!("[{}] {} · {}", elapsed, ctx, clean_msg),
|
|
120
|
+
None => format!("[{}] {}", elapsed, clean_msg),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Create a spinner for indeterminate progress (e.g., build steps)
|
|
125
|
+
pub fn add_spinner(&mut self, id: &str, message: &str) -> &ProgressBar {
|
|
126
|
+
let spinner = self.multi.add(ProgressBar::new_spinner());
|
|
127
|
+
spinner.set_style(
|
|
128
|
+
ProgressStyle::default_spinner()
|
|
129
|
+
.template("{spinner:.green} {msg}")
|
|
130
|
+
.expect("valid template")
|
|
131
|
+
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
|
|
132
|
+
);
|
|
133
|
+
spinner.set_message(self.format_message(message));
|
|
134
|
+
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
|
135
|
+
self.bars.insert(id.to_string(), spinner);
|
|
136
|
+
self.bars.get(id).expect("just inserted")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Create a progress bar for determinate progress (e.g., layer download)
|
|
140
|
+
///
|
|
141
|
+
/// `total` is in bytes
|
|
142
|
+
pub fn add_bar(&mut self, id: &str, total: u64) -> &ProgressBar {
|
|
143
|
+
let bar = self.multi.add(ProgressBar::new(total));
|
|
144
|
+
bar.set_style(
|
|
145
|
+
ProgressStyle::default_bar()
|
|
146
|
+
.template(
|
|
147
|
+
"{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}",
|
|
148
|
+
)
|
|
149
|
+
.expect("valid template")
|
|
150
|
+
.progress_chars("=>-"),
|
|
151
|
+
);
|
|
152
|
+
bar.enable_steady_tick(std::time::Duration::from_millis(100));
|
|
153
|
+
self.bars.insert(id.to_string(), bar);
|
|
154
|
+
self.bars.get(id).expect("just inserted")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Update progress for a layer (used during image pull)
|
|
158
|
+
///
|
|
159
|
+
/// `current` and `total` are in bytes, `status` is the Docker status message
|
|
160
|
+
pub fn update_layer(&mut self, layer_id: &str, current: u64, total: u64, status: &str) {
|
|
161
|
+
if let Some(bar) = self.bars.get(layer_id) {
|
|
162
|
+
// Update total if it changed (Docker sometimes updates this)
|
|
163
|
+
if bar.length() != Some(total) && total > 0 {
|
|
164
|
+
bar.set_length(total);
|
|
165
|
+
}
|
|
166
|
+
bar.set_position(current);
|
|
167
|
+
bar.set_message(status.to_string());
|
|
168
|
+
} else {
|
|
169
|
+
// Create new bar for this layer
|
|
170
|
+
let bar = self.add_bar(layer_id, total);
|
|
171
|
+
bar.set_position(current);
|
|
172
|
+
bar.set_message(status.to_string());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Update spinner message (used during build)
|
|
177
|
+
///
|
|
178
|
+
/// Updates are throttled to prevent flickering from rapid message changes.
|
|
179
|
+
/// "Step X/Y" messages always update immediately as they indicate significant progress.
|
|
180
|
+
pub fn update_spinner(&mut self, id: &str, message: &str) {
|
|
181
|
+
let now = Instant::now();
|
|
182
|
+
let is_step_message = message.starts_with("Step ");
|
|
183
|
+
|
|
184
|
+
// Check if we should throttle this update
|
|
185
|
+
if !is_step_message {
|
|
186
|
+
if let Some(last) = self.last_update.get(id) {
|
|
187
|
+
if now.duration_since(*last) < SPINNER_UPDATE_THROTTLE {
|
|
188
|
+
return; // Throttle: too soon since last update
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Skip if message is identical to last one
|
|
193
|
+
if let Some(last_msg) = self.last_message.get(id) {
|
|
194
|
+
if last_msg == message {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Perform the update with context and elapsed time
|
|
201
|
+
let formatted = self.format_message(message);
|
|
202
|
+
|
|
203
|
+
if let Some(spinner) = self.bars.get(id) {
|
|
204
|
+
spinner.set_message(formatted);
|
|
205
|
+
} else {
|
|
206
|
+
// Create new spinner if doesn't exist
|
|
207
|
+
self.add_spinner(id, message);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Track update time and message
|
|
211
|
+
self.last_update.insert(id.to_string(), now);
|
|
212
|
+
self.last_message
|
|
213
|
+
.insert(id.to_string(), message.to_string());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Mark a layer/step as complete
|
|
217
|
+
pub fn finish(&mut self, id: &str, message: &str) {
|
|
218
|
+
if let Some(bar) = self.bars.get(id) {
|
|
219
|
+
bar.finish_with_message(message.to_string());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Mark all progress as complete
|
|
224
|
+
pub fn finish_all(&self, message: &str) {
|
|
225
|
+
for bar in self.bars.values() {
|
|
226
|
+
bar.finish_with_message(message.to_string());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// Mark all progress as failed
|
|
231
|
+
pub fn abandon_all(&self, message: &str) {
|
|
232
|
+
for bar in self.bars.values() {
|
|
233
|
+
bar.abandon_with_message(message.to_string());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#[cfg(test)]
|
|
239
|
+
mod tests {
|
|
240
|
+
use super::*;
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn progress_reporter_creation() {
|
|
244
|
+
let reporter = ProgressReporter::new();
|
|
245
|
+
assert!(reporter.bars.is_empty());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[test]
|
|
249
|
+
fn progress_reporter_default() {
|
|
250
|
+
let reporter = ProgressReporter::default();
|
|
251
|
+
assert!(reporter.bars.is_empty());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn add_spinner_creates_entry() {
|
|
256
|
+
let mut reporter = ProgressReporter::new();
|
|
257
|
+
reporter.add_spinner("test", "Testing...");
|
|
258
|
+
assert!(reporter.bars.contains_key("test"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn add_bar_creates_entry() {
|
|
263
|
+
let mut reporter = ProgressReporter::new();
|
|
264
|
+
reporter.add_bar("layer1", 1000);
|
|
265
|
+
assert!(reporter.bars.contains_key("layer1"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn update_layer_creates_if_missing() {
|
|
270
|
+
let mut reporter = ProgressReporter::new();
|
|
271
|
+
reporter.update_layer("layer1", 500, 1000, "Downloading");
|
|
272
|
+
assert!(reporter.bars.contains_key("layer1"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn update_spinner_creates_if_missing() {
|
|
277
|
+
let mut reporter = ProgressReporter::new();
|
|
278
|
+
reporter.update_spinner("step1", "Building...");
|
|
279
|
+
assert!(reporter.bars.contains_key("step1"));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn finish_handles_missing_id() {
|
|
284
|
+
let mut reporter = ProgressReporter::new();
|
|
285
|
+
// Should not panic on missing id
|
|
286
|
+
reporter.finish("nonexistent", "Done");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[test]
|
|
290
|
+
fn finish_all_handles_empty() {
|
|
291
|
+
let reporter = ProgressReporter::new();
|
|
292
|
+
// Should not panic on empty
|
|
293
|
+
reporter.finish_all("Done");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#[test]
|
|
297
|
+
fn abandon_all_handles_empty() {
|
|
298
|
+
let reporter = ProgressReporter::new();
|
|
299
|
+
// Should not panic on empty
|
|
300
|
+
reporter.abandon_all("Failed");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[test]
|
|
304
|
+
fn format_elapsed_shows_seconds_only() {
|
|
305
|
+
let duration = Duration::from_secs(45);
|
|
306
|
+
assert_eq!(format_elapsed(duration), "00:45");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[test]
|
|
310
|
+
fn format_elapsed_shows_minutes_and_seconds() {
|
|
311
|
+
let duration = Duration::from_secs(90); // 1m 30s
|
|
312
|
+
assert_eq!(format_elapsed(duration), "01:30");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn format_elapsed_shows_hours_when_needed() {
|
|
317
|
+
let duration = Duration::from_secs(3661); // 1h 1m 1s
|
|
318
|
+
assert_eq!(format_elapsed(duration), "01:01:01");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn format_elapsed_zero() {
|
|
323
|
+
let duration = Duration::from_secs(0);
|
|
324
|
+
assert_eq!(format_elapsed(duration), "00:00");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#[test]
|
|
328
|
+
fn with_context_sets_context() {
|
|
329
|
+
let reporter = ProgressReporter::with_context("Building Docker image");
|
|
330
|
+
assert!(reporter.context.is_some());
|
|
331
|
+
assert_eq!(reporter.context.unwrap(), "Building Docker image");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn format_message_includes_context_for_steps() {
|
|
336
|
+
let reporter = ProgressReporter::with_context("Building Docker image");
|
|
337
|
+
let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
|
|
338
|
+
// Format: [elapsed] Context · message
|
|
339
|
+
assert!(msg.contains("Building Docker image · Step 1/10"));
|
|
340
|
+
assert!(msg.starts_with("[00:00]"));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#[test]
|
|
344
|
+
fn format_message_includes_context_for_all_messages() {
|
|
345
|
+
let reporter = ProgressReporter::with_context("Building Docker image");
|
|
346
|
+
let msg = reporter.format_message("Compiling foo v1.0");
|
|
347
|
+
// Format: [elapsed] Context · message
|
|
348
|
+
assert!(msg.contains("Building Docker image · Compiling foo"));
|
|
349
|
+
assert!(msg.starts_with("[00:00]"));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn format_message_without_context() {
|
|
354
|
+
let reporter = ProgressReporter::new();
|
|
355
|
+
let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
|
|
356
|
+
// Format: [elapsed] message (no context, no dot)
|
|
357
|
+
assert!(msg.contains("Step 1/10"));
|
|
358
|
+
assert!(msg.starts_with("[00:00]"));
|
|
359
|
+
assert!(!msg.contains("·"));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#[test]
|
|
363
|
+
fn format_message_collapses_whitespace() {
|
|
364
|
+
let reporter = ProgressReporter::new();
|
|
365
|
+
// All whitespace (including newlines) collapsed to single spaces for spinner display
|
|
366
|
+
let msg = reporter.format_message("Compiling foo\n Compiling bar\n");
|
|
367
|
+
assert!(!msg.contains('\n'));
|
|
368
|
+
assert!(msg.contains("Compiling foo Compiling bar"));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn strip_ansi_codes_removes_color_codes() {
|
|
373
|
+
// Red text: \x1b[31m ... \x1b[0m
|
|
374
|
+
let input = "\x1b[31mError:\x1b[0m something failed";
|
|
375
|
+
let result = strip_ansi_codes(input);
|
|
376
|
+
assert_eq!(result, "Error: something failed");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[test]
|
|
380
|
+
fn strip_ansi_codes_handles_plain_text() {
|
|
381
|
+
let input = "Just plain text";
|
|
382
|
+
let result = strip_ansi_codes(input);
|
|
383
|
+
assert_eq!(result, "Just plain text");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#[test]
|
|
387
|
+
fn strip_ansi_codes_handles_multiple_codes() {
|
|
388
|
+
// Bold green: \x1b[1;32m
|
|
389
|
+
let input = "\x1b[1;32mSuccess\x1b[0m and \x1b[33mwarning\x1b[0m";
|
|
390
|
+
let result = strip_ansi_codes(input);
|
|
391
|
+
assert_eq!(result, "Success and warning");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[test]
|
|
395
|
+
fn format_message_strips_ansi_codes() {
|
|
396
|
+
let reporter = ProgressReporter::new();
|
|
397
|
+
let msg = reporter.format_message("\x1b[31mCompiling\x1b[0m foo");
|
|
398
|
+
assert!(msg.contains("Compiling foo"));
|
|
399
|
+
assert!(!msg.contains("\x1b"));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//! Docker volume management
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functions to create and manage Docker volumes
|
|
4
|
+
//! for persistent storage across container restarts.
|
|
5
|
+
|
|
6
|
+
use super::{DockerClient, DockerError};
|
|
7
|
+
use bollard::volume::CreateVolumeOptions;
|
|
8
|
+
use std::collections::HashMap;
|
|
9
|
+
use tracing::debug;
|
|
10
|
+
|
|
11
|
+
/// Volume name for opencode session history
|
|
12
|
+
pub const VOLUME_SESSION: &str = "opencode-cloud-session";
|
|
13
|
+
|
|
14
|
+
/// Volume name for project files
|
|
15
|
+
pub const VOLUME_PROJECTS: &str = "opencode-cloud-projects";
|
|
16
|
+
|
|
17
|
+
/// Volume name for opencode configuration
|
|
18
|
+
pub const VOLUME_CONFIG: &str = "opencode-cloud-config";
|
|
19
|
+
|
|
20
|
+
/// All volume names as array for iteration
|
|
21
|
+
pub const VOLUME_NAMES: [&str; 3] = [VOLUME_SESSION, VOLUME_PROJECTS, VOLUME_CONFIG];
|
|
22
|
+
|
|
23
|
+
/// Mount point for session history inside container
|
|
24
|
+
pub const MOUNT_SESSION: &str = "/home/opencode/.opencode";
|
|
25
|
+
|
|
26
|
+
/// Mount point for project files inside container
|
|
27
|
+
pub const MOUNT_PROJECTS: &str = "/workspace";
|
|
28
|
+
|
|
29
|
+
/// Mount point for configuration inside container
|
|
30
|
+
pub const MOUNT_CONFIG: &str = "/home/opencode/.config";
|
|
31
|
+
|
|
32
|
+
/// Ensure all required volumes exist
|
|
33
|
+
///
|
|
34
|
+
/// Creates volumes if they don't exist. This operation is idempotent -
|
|
35
|
+
/// calling it multiple times has no additional effect.
|
|
36
|
+
pub async fn ensure_volumes_exist(client: &DockerClient) -> Result<(), DockerError> {
|
|
37
|
+
debug!("Ensuring all required volumes exist");
|
|
38
|
+
|
|
39
|
+
for volume_name in VOLUME_NAMES {
|
|
40
|
+
ensure_volume_exists(client, volume_name).await?;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
debug!("All volumes verified/created");
|
|
44
|
+
Ok(())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Ensure a specific volume exists
|
|
48
|
+
async fn ensure_volume_exists(client: &DockerClient, name: &str) -> Result<(), DockerError> {
|
|
49
|
+
debug!("Checking volume: {}", name);
|
|
50
|
+
|
|
51
|
+
// Create volume options with default local driver
|
|
52
|
+
let options = CreateVolumeOptions {
|
|
53
|
+
name,
|
|
54
|
+
driver: "local",
|
|
55
|
+
driver_opts: HashMap::new(),
|
|
56
|
+
labels: HashMap::from([("managed-by", "opencode-cloud")]),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// create_volume is idempotent - returns existing volume if it exists
|
|
60
|
+
client
|
|
61
|
+
.inner()
|
|
62
|
+
.create_volume(options)
|
|
63
|
+
.await
|
|
64
|
+
.map_err(|e| DockerError::Volume(format!("Failed to create volume {name}: {e}")))?;
|
|
65
|
+
|
|
66
|
+
debug!("Volume {} ready", name);
|
|
67
|
+
Ok(())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Check if a specific volume exists
|
|
71
|
+
pub async fn volume_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
|
|
72
|
+
debug!("Checking if volume exists: {}", name);
|
|
73
|
+
|
|
74
|
+
match client.inner().inspect_volume(name).await {
|
|
75
|
+
Ok(_) => Ok(true),
|
|
76
|
+
Err(bollard::errors::Error::DockerResponseServerError {
|
|
77
|
+
status_code: 404, ..
|
|
78
|
+
}) => Ok(false),
|
|
79
|
+
Err(e) => Err(DockerError::Volume(format!(
|
|
80
|
+
"Failed to inspect volume {name}: {e}"
|
|
81
|
+
))),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Remove a volume
|
|
86
|
+
///
|
|
87
|
+
/// Returns error if volume is in use by a container.
|
|
88
|
+
/// Use force_remove_volume for cleanup during uninstall.
|
|
89
|
+
pub async fn remove_volume(client: &DockerClient, name: &str) -> Result<(), DockerError> {
|
|
90
|
+
debug!("Removing volume: {}", name);
|
|
91
|
+
|
|
92
|
+
client
|
|
93
|
+
.inner()
|
|
94
|
+
.remove_volume(name, None)
|
|
95
|
+
.await
|
|
96
|
+
.map_err(|e| DockerError::Volume(format!("Failed to remove volume {name}: {e}")))?;
|
|
97
|
+
|
|
98
|
+
debug!("Volume {} removed", name);
|
|
99
|
+
Ok(())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Remove all opencode-cloud volumes
|
|
103
|
+
///
|
|
104
|
+
/// Used during uninstall. Fails if any volume is in use.
|
|
105
|
+
pub async fn remove_all_volumes(client: &DockerClient) -> Result<(), DockerError> {
|
|
106
|
+
debug!("Removing all opencode-cloud volumes");
|
|
107
|
+
|
|
108
|
+
for volume_name in VOLUME_NAMES {
|
|
109
|
+
// Check if volume exists before trying to remove
|
|
110
|
+
if volume_exists(client, volume_name).await? {
|
|
111
|
+
remove_volume(client, volume_name).await?;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
debug!("All volumes removed");
|
|
116
|
+
Ok(())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[cfg(test)]
|
|
120
|
+
mod tests {
|
|
121
|
+
use super::*;
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn volume_constants_are_correct() {
|
|
125
|
+
assert_eq!(VOLUME_SESSION, "opencode-cloud-session");
|
|
126
|
+
assert_eq!(VOLUME_PROJECTS, "opencode-cloud-projects");
|
|
127
|
+
assert_eq!(VOLUME_CONFIG, "opencode-cloud-config");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn volume_names_array_has_all_volumes() {
|
|
132
|
+
assert_eq!(VOLUME_NAMES.len(), 3);
|
|
133
|
+
assert!(VOLUME_NAMES.contains(&VOLUME_SESSION));
|
|
134
|
+
assert!(VOLUME_NAMES.contains(&VOLUME_PROJECTS));
|
|
135
|
+
assert!(VOLUME_NAMES.contains(&VOLUME_CONFIG));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn mount_points_are_correct() {
|
|
140
|
+
assert_eq!(MOUNT_SESSION, "/home/opencode/.opencode");
|
|
141
|
+
assert_eq!(MOUNT_PROJECTS, "/workspace");
|
|
142
|
+
assert_eq!(MOUNT_CONFIG, "/home/opencode/.config");
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/lib.rs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
//! and Node.js bindings via NAPI-RS.
|
|
5
5
|
|
|
6
6
|
pub mod config;
|
|
7
|
+
pub mod docker;
|
|
8
|
+
pub mod platform;
|
|
7
9
|
pub mod singleton;
|
|
8
10
|
pub mod version;
|
|
9
11
|
|
|
@@ -16,6 +18,18 @@ pub use config::{Config, load_config, save_config};
|
|
|
16
18
|
// Re-export singleton types
|
|
17
19
|
pub use singleton::{InstanceLock, SingletonError};
|
|
18
20
|
|
|
21
|
+
// Re-export docker types
|
|
22
|
+
pub use docker::{CONTAINER_NAME, DockerClient, DockerError, OPENCODE_WEB_PORT};
|
|
23
|
+
|
|
24
|
+
// Re-export platform types
|
|
25
|
+
pub use platform::{
|
|
26
|
+
InstallResult, ServiceConfig, ServiceManager, get_service_manager,
|
|
27
|
+
is_service_registration_supported,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Re-export bollard to ensure all crates use the same version
|
|
31
|
+
pub use bollard;
|
|
32
|
+
|
|
19
33
|
// NAPI bindings for Node.js consumers (only when napi feature is enabled)
|
|
20
34
|
#[cfg(feature = "napi")]
|
|
21
35
|
use napi_derive::napi;
|