@opencode-cloud/core 1.0.8 → 3.0.15
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 +9 -2
- package/README.md +56 -30
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +561 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +353 -207
- package/src/docker/README.dockerhub.md +39 -0
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +204 -36
- package/src/docker/dockerfile.rs +15 -12
- package/src/docker/exec.rs +278 -0
- package/src/docker/health.rs +165 -0
- package/src/docker/image.rs +2 -4
- package/src/docker/mod.rs +72 -8
- package/src/docker/mount.rs +330 -0
- package/src/docker/progress.rs +4 -4
- package/src/docker/state.rs +120 -0
- package/src/docker/update.rs +156 -0
- package/src/docker/users.rs +357 -0
- package/src/docker/version.rs +95 -0
- package/src/host/error.rs +61 -0
- package/src/host/mod.rs +29 -0
- package/src/host/provision.rs +394 -0
- package/src/host/schema.rs +308 -0
- package/src/host/ssh_config.rs +282 -0
- package/src/host/storage.rs +118 -0
- package/src/host/tunnel.rs +268 -0
- package/src/lib.rs +10 -1
- package/src/platform/launchd.rs +1 -1
- package/src/platform/systemd.rs +6 -6
- package/src/singleton/mod.rs +1 -1
- package/src/version.rs +1 -6
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
//! Bind mount parsing and validation for container configuration.
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functionality to:
|
|
4
|
+
//! - Parse mount strings in Docker format (`/host:/container[:ro|rw]`)
|
|
5
|
+
//! - Validate mount paths (existence, type, permissions)
|
|
6
|
+
//! - Convert parsed mounts to Bollard's Mount type for Docker API
|
|
7
|
+
//! - Warn about potentially dangerous container mount points
|
|
8
|
+
|
|
9
|
+
use bollard::service::{Mount, MountTypeEnum};
|
|
10
|
+
use std::path::PathBuf;
|
|
11
|
+
use thiserror::Error;
|
|
12
|
+
|
|
13
|
+
/// Errors that can occur during mount parsing and validation.
|
|
14
|
+
#[derive(Debug, Error)]
|
|
15
|
+
pub enum MountError {
|
|
16
|
+
/// Mount path is relative, but must be absolute.
|
|
17
|
+
#[error("Mount paths must be absolute. Use: /full/path/to/dir (got: {0})")]
|
|
18
|
+
RelativePath(String),
|
|
19
|
+
|
|
20
|
+
/// Mount string format is invalid.
|
|
21
|
+
#[error("Invalid mount format. Expected: /host/path:/container/path[:ro] (got: {0})")]
|
|
22
|
+
InvalidFormat(String),
|
|
23
|
+
|
|
24
|
+
/// Path does not exist or cannot be accessed.
|
|
25
|
+
#[error("Path not found: {0} ({1})")]
|
|
26
|
+
PathNotFound(String, String),
|
|
27
|
+
|
|
28
|
+
/// Path exists but is not a directory.
|
|
29
|
+
#[error("Path is not a directory: {0}")]
|
|
30
|
+
NotADirectory(String),
|
|
31
|
+
|
|
32
|
+
/// Permission denied accessing path.
|
|
33
|
+
#[error("Cannot access path (permission denied): {0}")]
|
|
34
|
+
PermissionDenied(String),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// A parsed bind mount specification.
|
|
38
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
39
|
+
pub struct ParsedMount {
|
|
40
|
+
/// Host path to mount (absolute).
|
|
41
|
+
pub host_path: PathBuf,
|
|
42
|
+
|
|
43
|
+
/// Container path where the host path is mounted.
|
|
44
|
+
pub container_path: String,
|
|
45
|
+
|
|
46
|
+
/// Whether the mount is read-only.
|
|
47
|
+
pub read_only: bool,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl ParsedMount {
|
|
51
|
+
/// Parse a mount string in Docker format.
|
|
52
|
+
///
|
|
53
|
+
/// Format: `/host/path:/container/path[:ro|rw]`
|
|
54
|
+
///
|
|
55
|
+
/// # Arguments
|
|
56
|
+
/// * `mount_str` - The mount specification string.
|
|
57
|
+
///
|
|
58
|
+
/// # Returns
|
|
59
|
+
/// * `Ok(ParsedMount)` - Successfully parsed mount.
|
|
60
|
+
/// * `Err(MountError)` - Parse error.
|
|
61
|
+
///
|
|
62
|
+
/// # Examples
|
|
63
|
+
/// ```
|
|
64
|
+
/// use opencode_cloud_core::docker::ParsedMount;
|
|
65
|
+
///
|
|
66
|
+
/// // Read-write mount (default)
|
|
67
|
+
/// let mount = ParsedMount::parse("/home/user/data:/workspace/data").unwrap();
|
|
68
|
+
/// assert_eq!(mount.host_path.to_str().unwrap(), "/home/user/data");
|
|
69
|
+
/// assert_eq!(mount.container_path, "/workspace/data");
|
|
70
|
+
/// assert!(!mount.read_only);
|
|
71
|
+
///
|
|
72
|
+
/// // Read-only mount
|
|
73
|
+
/// let mount = ParsedMount::parse("/home/user/config:/etc/app:ro").unwrap();
|
|
74
|
+
/// assert!(mount.read_only);
|
|
75
|
+
/// ```
|
|
76
|
+
pub fn parse(mount_str: &str) -> Result<Self, MountError> {
|
|
77
|
+
let parts: Vec<&str> = mount_str.split(':').collect();
|
|
78
|
+
|
|
79
|
+
match parts.len() {
|
|
80
|
+
2 => {
|
|
81
|
+
// /host:/container (default rw)
|
|
82
|
+
let host_path = PathBuf::from(parts[0]);
|
|
83
|
+
if !host_path.is_absolute() {
|
|
84
|
+
return Err(MountError::RelativePath(parts[0].to_string()));
|
|
85
|
+
}
|
|
86
|
+
Ok(Self {
|
|
87
|
+
host_path,
|
|
88
|
+
container_path: parts[1].to_string(),
|
|
89
|
+
read_only: false,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
3 => {
|
|
93
|
+
// /host:/container:ro or /host:/container:rw
|
|
94
|
+
let host_path = PathBuf::from(parts[0]);
|
|
95
|
+
if !host_path.is_absolute() {
|
|
96
|
+
return Err(MountError::RelativePath(parts[0].to_string()));
|
|
97
|
+
}
|
|
98
|
+
let read_only = match parts[2].to_lowercase().as_str() {
|
|
99
|
+
"ro" => true,
|
|
100
|
+
"rw" => false,
|
|
101
|
+
_ => return Err(MountError::InvalidFormat(mount_str.to_string())),
|
|
102
|
+
};
|
|
103
|
+
Ok(Self {
|
|
104
|
+
host_path,
|
|
105
|
+
container_path: parts[1].to_string(),
|
|
106
|
+
read_only,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
_ => Err(MountError::InvalidFormat(mount_str.to_string())),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Convert to a Bollard Mount for the Docker API.
|
|
114
|
+
///
|
|
115
|
+
/// Returns a bind mount with the parsed host and container paths.
|
|
116
|
+
pub fn to_bollard_mount(&self) -> Mount {
|
|
117
|
+
Mount {
|
|
118
|
+
target: Some(self.container_path.clone()),
|
|
119
|
+
source: Some(self.host_path.to_string_lossy().to_string()),
|
|
120
|
+
typ: Some(MountTypeEnum::BIND),
|
|
121
|
+
read_only: Some(self.read_only),
|
|
122
|
+
..Default::default()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Validate that a mount host path exists and is accessible.
|
|
128
|
+
///
|
|
129
|
+
/// Checks:
|
|
130
|
+
/// 1. Path is absolute.
|
|
131
|
+
/// 2. Path exists (via canonicalize, which also resolves symlinks).
|
|
132
|
+
/// 3. Path is a directory.
|
|
133
|
+
///
|
|
134
|
+
/// # Arguments
|
|
135
|
+
/// * `path` - The path to validate.
|
|
136
|
+
///
|
|
137
|
+
/// # Returns
|
|
138
|
+
/// * `Ok(PathBuf)` - The canonical (resolved) path.
|
|
139
|
+
/// * `Err(MountError)` - Validation error.
|
|
140
|
+
pub fn validate_mount_path(path: &std::path::Path) -> Result<PathBuf, MountError> {
|
|
141
|
+
// Check absolute
|
|
142
|
+
if !path.is_absolute() {
|
|
143
|
+
return Err(MountError::RelativePath(path.display().to_string()));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Canonicalize (resolves symlinks, checks existence)
|
|
147
|
+
let canonical = std::fs::canonicalize(path).map_err(|e| {
|
|
148
|
+
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
|
149
|
+
MountError::PermissionDenied(path.display().to_string())
|
|
150
|
+
} else {
|
|
151
|
+
MountError::PathNotFound(path.display().to_string(), e.to_string())
|
|
152
|
+
}
|
|
153
|
+
})?;
|
|
154
|
+
|
|
155
|
+
// Check it's a directory
|
|
156
|
+
let metadata = std::fs::metadata(&canonical).map_err(|e| {
|
|
157
|
+
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
|
158
|
+
MountError::PermissionDenied(path.display().to_string())
|
|
159
|
+
} else {
|
|
160
|
+
MountError::PathNotFound(path.display().to_string(), e.to_string())
|
|
161
|
+
}
|
|
162
|
+
})?;
|
|
163
|
+
|
|
164
|
+
if !metadata.is_dir() {
|
|
165
|
+
return Err(MountError::NotADirectory(path.display().to_string()));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Ok(canonical)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// System paths that should typically not be mounted over.
|
|
172
|
+
const SYSTEM_PATHS: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/lib", "/var"];
|
|
173
|
+
|
|
174
|
+
/// Check if mounting to a container path might be dangerous.
|
|
175
|
+
///
|
|
176
|
+
/// Returns a warning message if the container path is a system path,
|
|
177
|
+
/// or `None` if the path appears safe.
|
|
178
|
+
///
|
|
179
|
+
/// # Arguments
|
|
180
|
+
/// * `container_path` - The path inside the container.
|
|
181
|
+
///
|
|
182
|
+
/// # Returns
|
|
183
|
+
/// * `Some(String)` - Warning message about the system path.
|
|
184
|
+
/// * `None` - Path appears safe.
|
|
185
|
+
pub fn check_container_path_warning(container_path: &str) -> Option<String> {
|
|
186
|
+
for system_path in SYSTEM_PATHS {
|
|
187
|
+
if container_path == *system_path || container_path.starts_with(&format!("{system_path}/"))
|
|
188
|
+
{
|
|
189
|
+
return Some(format!(
|
|
190
|
+
"Warning: mounting to '{container_path}' may affect container system files"
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
None
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[cfg(test)]
|
|
198
|
+
mod tests {
|
|
199
|
+
use super::*;
|
|
200
|
+
|
|
201
|
+
#[test]
|
|
202
|
+
fn parse_valid_mount_rw() {
|
|
203
|
+
let mount = ParsedMount::parse("/a:/b").unwrap();
|
|
204
|
+
assert_eq!(mount.host_path, PathBuf::from("/a"));
|
|
205
|
+
assert_eq!(mount.container_path, "/b");
|
|
206
|
+
assert!(!mount.read_only);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#[test]
|
|
210
|
+
fn parse_valid_mount_ro() {
|
|
211
|
+
let mount = ParsedMount::parse("/a:/b:ro").unwrap();
|
|
212
|
+
assert_eq!(mount.host_path, PathBuf::from("/a"));
|
|
213
|
+
assert_eq!(mount.container_path, "/b");
|
|
214
|
+
assert!(mount.read_only);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn parse_valid_mount_explicit_rw() {
|
|
219
|
+
let mount = ParsedMount::parse("/a:/b:rw").unwrap();
|
|
220
|
+
assert_eq!(mount.host_path, PathBuf::from("/a"));
|
|
221
|
+
assert_eq!(mount.container_path, "/b");
|
|
222
|
+
assert!(!mount.read_only);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn parse_valid_mount_ro_uppercase() {
|
|
227
|
+
let mount = ParsedMount::parse("/a:/b:RO").unwrap();
|
|
228
|
+
assert!(mount.read_only);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[test]
|
|
232
|
+
fn parse_invalid_format_single_part() {
|
|
233
|
+
let result = ParsedMount::parse("invalid");
|
|
234
|
+
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[test]
|
|
238
|
+
fn parse_invalid_format_too_many_parts() {
|
|
239
|
+
let result = ParsedMount::parse("/a:/b:ro:extra");
|
|
240
|
+
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn parse_invalid_format_bad_mode() {
|
|
245
|
+
let result = ParsedMount::parse("/a:/b:invalid");
|
|
246
|
+
assert!(matches!(result, Err(MountError::InvalidFormat(_))));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn parse_relative_path_rejected() {
|
|
251
|
+
let result = ParsedMount::parse("./rel:/b");
|
|
252
|
+
assert!(matches!(result, Err(MountError::RelativePath(_))));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[test]
|
|
256
|
+
fn parse_relative_path_no_dot_rejected() {
|
|
257
|
+
let result = ParsedMount::parse("relative/path:/b");
|
|
258
|
+
assert!(matches!(result, Err(MountError::RelativePath(_))));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn system_path_warning_etc() {
|
|
263
|
+
let warning = check_container_path_warning("/etc");
|
|
264
|
+
assert!(warning.is_some());
|
|
265
|
+
assert!(warning.unwrap().contains("/etc"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn system_path_warning_etc_subdir() {
|
|
270
|
+
let warning = check_container_path_warning("/etc/passwd");
|
|
271
|
+
assert!(warning.is_some());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[test]
|
|
275
|
+
fn system_path_warning_usr() {
|
|
276
|
+
let warning = check_container_path_warning("/usr");
|
|
277
|
+
assert!(warning.is_some());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[test]
|
|
281
|
+
fn system_path_warning_usr_local() {
|
|
282
|
+
let warning = check_container_path_warning("/usr/local");
|
|
283
|
+
assert!(warning.is_some());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn non_system_path_no_warning() {
|
|
288
|
+
let warning = check_container_path_warning("/workspace/data");
|
|
289
|
+
assert!(warning.is_none());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[test]
|
|
293
|
+
fn non_system_path_home_no_warning() {
|
|
294
|
+
let warning = check_container_path_warning("/home/user/data");
|
|
295
|
+
assert!(warning.is_none());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[test]
|
|
299
|
+
fn to_bollard_mount_structure() {
|
|
300
|
+
let mount = ParsedMount {
|
|
301
|
+
host_path: PathBuf::from("/host/path"),
|
|
302
|
+
container_path: "/container/path".to_string(),
|
|
303
|
+
read_only: true,
|
|
304
|
+
};
|
|
305
|
+
let bollard_mount = mount.to_bollard_mount();
|
|
306
|
+
assert_eq!(bollard_mount.target, Some("/container/path".to_string()));
|
|
307
|
+
assert_eq!(bollard_mount.source, Some("/host/path".to_string()));
|
|
308
|
+
assert_eq!(bollard_mount.typ, Some(MountTypeEnum::BIND));
|
|
309
|
+
assert_eq!(bollard_mount.read_only, Some(true));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[test]
|
|
313
|
+
fn validate_mount_path_relative_rejected() {
|
|
314
|
+
let result = validate_mount_path(std::path::Path::new("./relative"));
|
|
315
|
+
assert!(matches!(result, Err(MountError::RelativePath(_))));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#[test]
|
|
319
|
+
fn validate_mount_path_nonexistent() {
|
|
320
|
+
let result = validate_mount_path(std::path::Path::new("/nonexistent/path/xyz123"));
|
|
321
|
+
assert!(matches!(result, Err(MountError::PathNotFound(_, _))));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#[test]
|
|
325
|
+
fn validate_mount_path_existing_directory() {
|
|
326
|
+
// Use /tmp which should exist on any Unix system
|
|
327
|
+
let result = validate_mount_path(std::path::Path::new("/tmp"));
|
|
328
|
+
assert!(result.is_ok());
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/docker/progress.rs
CHANGED
|
@@ -48,9 +48,9 @@ fn format_elapsed(duration: Duration) -> String {
|
|
|
48
48
|
let seconds = total_secs % 60;
|
|
49
49
|
|
|
50
50
|
if hours > 0 {
|
|
51
|
-
format!("{:02}:{:02}:{:02}"
|
|
51
|
+
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
|
52
52
|
} else {
|
|
53
|
-
format!("{:02}:{:02}"
|
|
53
|
+
format!("{minutes:02}:{seconds:02}")
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -116,8 +116,8 @@ impl ProgressReporter {
|
|
|
116
116
|
// Format: "[elapsed] Context · message" or "[elapsed] message"
|
|
117
117
|
// Timer at the beginning for easy scanning
|
|
118
118
|
match &self.context {
|
|
119
|
-
Some(ctx) => format!("[{}] {} · {}"
|
|
120
|
-
None => format!("[{}] {}"
|
|
119
|
+
Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
|
|
120
|
+
None => format!("[{elapsed}] {clean_msg}"),
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//! Image state tracking for provenance information
|
|
2
|
+
//!
|
|
3
|
+
//! Tracks where the current Docker image came from (prebuilt or built)
|
|
4
|
+
//! and which registry it was pulled from.
|
|
5
|
+
|
|
6
|
+
use chrono::Utc;
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use std::path::PathBuf;
|
|
9
|
+
|
|
10
|
+
/// Image provenance state
|
|
11
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
12
|
+
pub struct ImageState {
|
|
13
|
+
/// Image version (e.g., "1.0.12")
|
|
14
|
+
pub version: String,
|
|
15
|
+
/// Source: "prebuilt" or "build"
|
|
16
|
+
pub source: String,
|
|
17
|
+
/// Registry if prebuilt: "ghcr.io" or "docker.io", None for build
|
|
18
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
19
|
+
pub registry: Option<String>,
|
|
20
|
+
/// When the image was acquired (ISO8601)
|
|
21
|
+
pub acquired_at: String,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl ImageState {
|
|
25
|
+
/// Create a new ImageState for a prebuilt image
|
|
26
|
+
pub fn prebuilt(version: &str, registry: &str) -> Self {
|
|
27
|
+
Self {
|
|
28
|
+
version: version.to_string(),
|
|
29
|
+
source: "prebuilt".to_string(),
|
|
30
|
+
registry: Some(registry.to_string()),
|
|
31
|
+
acquired_at: Utc::now().to_rfc3339(),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Create a new ImageState for a locally built image
|
|
36
|
+
pub fn built(version: &str) -> Self {
|
|
37
|
+
Self {
|
|
38
|
+
version: version.to_string(),
|
|
39
|
+
source: "build".to_string(),
|
|
40
|
+
registry: None,
|
|
41
|
+
acquired_at: Utc::now().to_rfc3339(),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Get the path to the image state file
|
|
47
|
+
pub fn get_state_path() -> Option<PathBuf> {
|
|
48
|
+
crate::config::paths::get_data_dir().map(|p| p.join("image-state.json"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Save image state to disk
|
|
52
|
+
pub fn save_state(state: &ImageState) -> anyhow::Result<()> {
|
|
53
|
+
let path = get_state_path().ok_or_else(|| anyhow::anyhow!("Could not determine state path"))?;
|
|
54
|
+
|
|
55
|
+
// Ensure parent directory exists
|
|
56
|
+
if let Some(parent) = path.parent() {
|
|
57
|
+
std::fs::create_dir_all(parent)?;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let json = serde_json::to_string_pretty(state)?;
|
|
61
|
+
std::fs::write(&path, json)?;
|
|
62
|
+
Ok(())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Load image state from disk
|
|
66
|
+
pub fn load_state() -> Option<ImageState> {
|
|
67
|
+
let path = get_state_path()?;
|
|
68
|
+
let content = std::fs::read_to_string(&path).ok()?;
|
|
69
|
+
serde_json::from_str(&content).ok()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Clear image state (e.g., after image removal)
|
|
73
|
+
pub fn clear_state() -> anyhow::Result<()> {
|
|
74
|
+
if let Some(path) = get_state_path() {
|
|
75
|
+
if path.exists() {
|
|
76
|
+
std::fs::remove_file(&path)?;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
Ok(())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[cfg(test)]
|
|
83
|
+
mod tests {
|
|
84
|
+
use super::*;
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn test_image_state_prebuilt() {
|
|
88
|
+
let state = ImageState::prebuilt("1.0.12", "ghcr.io");
|
|
89
|
+
assert_eq!(state.version, "1.0.12");
|
|
90
|
+
assert_eq!(state.source, "prebuilt");
|
|
91
|
+
assert_eq!(state.registry, Some("ghcr.io".to_string()));
|
|
92
|
+
assert!(!state.acquired_at.is_empty());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn test_image_state_built() {
|
|
97
|
+
let state = ImageState::built("1.0.12");
|
|
98
|
+
assert_eq!(state.version, "1.0.12");
|
|
99
|
+
assert_eq!(state.source, "build");
|
|
100
|
+
assert!(state.registry.is_none());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[test]
|
|
104
|
+
fn test_image_state_serialize_deserialize() {
|
|
105
|
+
let state = ImageState::prebuilt("1.0.12", "docker.io");
|
|
106
|
+
let json = serde_json::to_string(&state).unwrap();
|
|
107
|
+
let parsed: ImageState = serde_json::from_str(&json).unwrap();
|
|
108
|
+
assert_eq!(state.version, parsed.version);
|
|
109
|
+
assert_eq!(state.source, parsed.source);
|
|
110
|
+
assert_eq!(state.registry, parsed.registry);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn test_get_state_path() {
|
|
115
|
+
let path = get_state_path();
|
|
116
|
+
assert!(path.is_some());
|
|
117
|
+
let p = path.unwrap();
|
|
118
|
+
assert!(p.to_string_lossy().contains("image-state.json"));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//! Docker image update and rollback operations
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functionality to update the opencode image to the latest
|
|
4
|
+
//! version and rollback to a previous version if needed.
|
|
5
|
+
|
|
6
|
+
use super::image::{image_exists, pull_image};
|
|
7
|
+
use super::progress::ProgressReporter;
|
|
8
|
+
use super::{DockerClient, DockerError, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
9
|
+
use bollard::image::TagImageOptions;
|
|
10
|
+
use tracing::debug;
|
|
11
|
+
|
|
12
|
+
/// Tag for the previous image version (used for rollback)
|
|
13
|
+
pub const PREVIOUS_TAG: &str = "previous";
|
|
14
|
+
|
|
15
|
+
/// Result of an update operation
|
|
16
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
17
|
+
pub enum UpdateResult {
|
|
18
|
+
/// Update completed successfully
|
|
19
|
+
Success,
|
|
20
|
+
/// Already on the latest version
|
|
21
|
+
AlreadyLatest,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Tag the current image as "previous" for rollback support
|
|
25
|
+
///
|
|
26
|
+
/// This allows users to rollback to the version they had before updating.
|
|
27
|
+
/// If the current image doesn't exist, this is silently skipped.
|
|
28
|
+
///
|
|
29
|
+
/// # Arguments
|
|
30
|
+
/// * `client` - Docker client
|
|
31
|
+
pub async fn tag_current_as_previous(client: &DockerClient) -> Result<(), DockerError> {
|
|
32
|
+
let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
33
|
+
let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
|
|
34
|
+
|
|
35
|
+
debug!(
|
|
36
|
+
"Tagging current image {} as {}",
|
|
37
|
+
current_image, previous_image
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Check if current image exists
|
|
41
|
+
if !image_exists(client, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT).await? {
|
|
42
|
+
debug!("Current image not found, skipping backup tag");
|
|
43
|
+
return Ok(());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Tag current as previous
|
|
47
|
+
let options = TagImageOptions {
|
|
48
|
+
repo: IMAGE_NAME_GHCR,
|
|
49
|
+
tag: PREVIOUS_TAG,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
client
|
|
53
|
+
.inner()
|
|
54
|
+
.tag_image(¤t_image, Some(options))
|
|
55
|
+
.await
|
|
56
|
+
.map_err(|e| {
|
|
57
|
+
DockerError::Container(format!("Failed to tag current image as previous: {e}"))
|
|
58
|
+
})?;
|
|
59
|
+
|
|
60
|
+
debug!("Successfully tagged current image as previous");
|
|
61
|
+
Ok(())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Check if a previous image exists for rollback
|
|
65
|
+
///
|
|
66
|
+
/// Returns true if a rollback is possible, false otherwise.
|
|
67
|
+
///
|
|
68
|
+
/// # Arguments
|
|
69
|
+
/// * `client` - Docker client
|
|
70
|
+
pub async fn has_previous_image(client: &DockerClient) -> Result<bool, DockerError> {
|
|
71
|
+
image_exists(client, IMAGE_NAME_GHCR, PREVIOUS_TAG).await
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Update the opencode image to the latest version
|
|
75
|
+
///
|
|
76
|
+
/// This operation:
|
|
77
|
+
/// 1. Tags the current image as "previous" for rollback
|
|
78
|
+
/// 2. Pulls the latest image from the registry
|
|
79
|
+
///
|
|
80
|
+
/// Returns UpdateResult indicating success or if already on latest.
|
|
81
|
+
///
|
|
82
|
+
/// # Arguments
|
|
83
|
+
/// * `client` - Docker client
|
|
84
|
+
/// * `progress` - Progress reporter for user feedback
|
|
85
|
+
pub async fn update_image(
|
|
86
|
+
client: &DockerClient,
|
|
87
|
+
progress: &mut ProgressReporter,
|
|
88
|
+
) -> Result<UpdateResult, DockerError> {
|
|
89
|
+
// Step 1: Tag current image as previous for rollback
|
|
90
|
+
progress.add_spinner("backup", "Backing up current image");
|
|
91
|
+
tag_current_as_previous(client).await?;
|
|
92
|
+
progress.finish("backup", "Current image backed up");
|
|
93
|
+
|
|
94
|
+
// Step 2: Pull latest image
|
|
95
|
+
progress.add_spinner("pull", "Pulling latest image");
|
|
96
|
+
pull_image(client, Some(IMAGE_TAG_DEFAULT), progress).await?;
|
|
97
|
+
progress.finish("pull", "Latest image pulled");
|
|
98
|
+
|
|
99
|
+
Ok(UpdateResult::Success)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Rollback to the previous image version
|
|
103
|
+
///
|
|
104
|
+
/// This re-tags the "previous" image as "latest", effectively reverting
|
|
105
|
+
/// to the version that was active before the last update.
|
|
106
|
+
///
|
|
107
|
+
/// Returns an error if no previous image exists.
|
|
108
|
+
///
|
|
109
|
+
/// # Arguments
|
|
110
|
+
/// * `client` - Docker client
|
|
111
|
+
pub async fn rollback_image(client: &DockerClient) -> Result<(), DockerError> {
|
|
112
|
+
// Check if previous image exists
|
|
113
|
+
if !has_previous_image(client).await? {
|
|
114
|
+
return Err(DockerError::Container(
|
|
115
|
+
"No previous image available for rollback. Update at least once before using rollback."
|
|
116
|
+
.to_string(),
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
|
|
121
|
+
let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
122
|
+
|
|
123
|
+
debug!("Rolling back from {} to {}", current_image, previous_image);
|
|
124
|
+
|
|
125
|
+
// Re-tag previous as latest
|
|
126
|
+
let options = TagImageOptions {
|
|
127
|
+
repo: IMAGE_NAME_GHCR,
|
|
128
|
+
tag: IMAGE_TAG_DEFAULT,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
client
|
|
132
|
+
.inner()
|
|
133
|
+
.tag_image(&previous_image, Some(options))
|
|
134
|
+
.await
|
|
135
|
+
.map_err(|e| DockerError::Container(format!("Failed to rollback image: {e}")))?;
|
|
136
|
+
|
|
137
|
+
debug!("Successfully rolled back to previous image");
|
|
138
|
+
Ok(())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[cfg(test)]
|
|
142
|
+
mod tests {
|
|
143
|
+
use super::*;
|
|
144
|
+
|
|
145
|
+
#[test]
|
|
146
|
+
fn previous_tag_constant() {
|
|
147
|
+
assert_eq!(PREVIOUS_TAG, "previous");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn update_result_variants() {
|
|
152
|
+
assert_eq!(UpdateResult::Success, UpdateResult::Success);
|
|
153
|
+
assert_eq!(UpdateResult::AlreadyLatest, UpdateResult::AlreadyLatest);
|
|
154
|
+
assert_ne!(UpdateResult::Success, UpdateResult::AlreadyLatest);
|
|
155
|
+
}
|
|
156
|
+
}
|