@opencode-cloud/core 1.0.10 → 3.1.1
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 +3 -3
- package/README.md +49 -30
- package/package.json +1 -1
- package/src/config/schema.rs +91 -0
- package/src/docker/Dockerfile +105 -84
- package/src/docker/README.dockerhub.md +39 -0
- package/src/docker/container.rs +114 -3
- package/src/docker/dockerfile.rs +15 -12
- package/src/docker/mod.rs +25 -4
- package/src/docker/mount.rs +330 -0
- package/src/docker/state.rs +120 -0
- package/src/host/ssh_config.rs +1 -1
package/src/docker/container.rs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//! Docker containers for the opencode-cloud service.
|
|
5
5
|
|
|
6
6
|
use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
|
|
7
|
+
use super::mount::ParsedMount;
|
|
7
8
|
use super::volume::{
|
|
8
9
|
MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
|
|
9
10
|
};
|
|
@@ -12,7 +13,9 @@ use bollard::container::{
|
|
|
12
13
|
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
|
13
14
|
StopContainerOptions,
|
|
14
15
|
};
|
|
15
|
-
use bollard::service::{
|
|
16
|
+
use bollard::service::{
|
|
17
|
+
HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
|
|
18
|
+
};
|
|
16
19
|
use std::collections::HashMap;
|
|
17
20
|
use tracing::debug;
|
|
18
21
|
|
|
@@ -36,6 +39,7 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
|
|
|
36
39
|
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
|
|
37
40
|
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
|
|
38
41
|
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
|
|
42
|
+
/// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
|
|
39
43
|
#[allow(clippy::too_many_arguments)]
|
|
40
44
|
pub async fn create_container(
|
|
41
45
|
client: &DockerClient,
|
|
@@ -46,6 +50,7 @@ pub async fn create_container(
|
|
|
46
50
|
bind_address: Option<&str>,
|
|
47
51
|
cockpit_port: Option<u16>,
|
|
48
52
|
cockpit_enabled: Option<bool>,
|
|
53
|
+
bind_mounts: Option<Vec<ParsedMount>>,
|
|
49
54
|
) -> Result<String, DockerError> {
|
|
50
55
|
let container_name = name.unwrap_or(CONTAINER_NAME);
|
|
51
56
|
let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
@@ -81,7 +86,7 @@ pub async fn create_container(
|
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
// Create volume mounts
|
|
84
|
-
let mounts = vec![
|
|
89
|
+
let mut mounts = vec![
|
|
85
90
|
Mount {
|
|
86
91
|
target: Some(MOUNT_SESSION.to_string()),
|
|
87
92
|
source: Some(VOLUME_SESSION.to_string()),
|
|
@@ -105,6 +110,13 @@ pub async fn create_container(
|
|
|
105
110
|
},
|
|
106
111
|
];
|
|
107
112
|
|
|
113
|
+
// Add user-defined bind mounts from config/CLI
|
|
114
|
+
if let Some(ref user_mounts) = bind_mounts {
|
|
115
|
+
for parsed in user_mounts {
|
|
116
|
+
mounts.push(parsed.to_bollard_mount());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
108
120
|
// Create port bindings (default to localhost for security)
|
|
109
121
|
let bind_addr = bind_address.unwrap_or("127.0.0.1");
|
|
110
122
|
let mut port_bindings: PortMap = HashMap::new();
|
|
@@ -356,6 +368,105 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
|
|
|
356
368
|
}
|
|
357
369
|
}
|
|
358
370
|
|
|
371
|
+
/// Container port configuration
|
|
372
|
+
#[derive(Debug, Clone)]
|
|
373
|
+
pub struct ContainerPorts {
|
|
374
|
+
/// Host port for opencode web UI (mapped from container port 3000)
|
|
375
|
+
pub opencode_port: Option<u16>,
|
|
376
|
+
/// Host port for Cockpit (mapped from container port 9090)
|
|
377
|
+
pub cockpit_port: Option<u16>,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// A bind mount from an existing container
|
|
381
|
+
#[derive(Debug, Clone)]
|
|
382
|
+
pub struct ContainerBindMount {
|
|
383
|
+
/// Source path on host
|
|
384
|
+
pub source: String,
|
|
385
|
+
/// Target path in container
|
|
386
|
+
pub target: String,
|
|
387
|
+
/// Read-only flag
|
|
388
|
+
pub read_only: bool,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Get the port bindings from an existing container
|
|
392
|
+
///
|
|
393
|
+
/// Returns the host ports that the container's internal ports are mapped to.
|
|
394
|
+
/// Returns None for ports that aren't mapped.
|
|
395
|
+
pub async fn get_container_ports(
|
|
396
|
+
client: &DockerClient,
|
|
397
|
+
name: &str,
|
|
398
|
+
) -> Result<ContainerPorts, DockerError> {
|
|
399
|
+
debug!("Getting container ports: {}", name);
|
|
400
|
+
|
|
401
|
+
let info = client
|
|
402
|
+
.inner()
|
|
403
|
+
.inspect_container(name, None)
|
|
404
|
+
.await
|
|
405
|
+
.map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
|
|
406
|
+
|
|
407
|
+
let port_bindings = info
|
|
408
|
+
.host_config
|
|
409
|
+
.and_then(|hc| hc.port_bindings)
|
|
410
|
+
.unwrap_or_default();
|
|
411
|
+
|
|
412
|
+
// Extract opencode port (3000/tcp -> host port)
|
|
413
|
+
let opencode_port = port_bindings
|
|
414
|
+
.get("3000/tcp")
|
|
415
|
+
.and_then(|bindings| bindings.as_ref())
|
|
416
|
+
.and_then(|bindings| bindings.first())
|
|
417
|
+
.and_then(|binding| binding.host_port.as_ref())
|
|
418
|
+
.and_then(|port_str| port_str.parse::<u16>().ok());
|
|
419
|
+
|
|
420
|
+
// Extract cockpit port (9090/tcp -> host port)
|
|
421
|
+
let cockpit_port = port_bindings
|
|
422
|
+
.get("9090/tcp")
|
|
423
|
+
.and_then(|bindings| bindings.as_ref())
|
|
424
|
+
.and_then(|bindings| bindings.first())
|
|
425
|
+
.and_then(|binding| binding.host_port.as_ref())
|
|
426
|
+
.and_then(|port_str| port_str.parse::<u16>().ok());
|
|
427
|
+
|
|
428
|
+
Ok(ContainerPorts {
|
|
429
|
+
opencode_port,
|
|
430
|
+
cockpit_port,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// Get bind mounts from an existing container
|
|
435
|
+
///
|
|
436
|
+
/// Returns only user-defined bind mounts (excludes system mounts like cgroup).
|
|
437
|
+
pub async fn get_container_bind_mounts(
|
|
438
|
+
client: &DockerClient,
|
|
439
|
+
name: &str,
|
|
440
|
+
) -> Result<Vec<ContainerBindMount>, DockerError> {
|
|
441
|
+
debug!("Getting container bind mounts: {}", name);
|
|
442
|
+
|
|
443
|
+
let info = client
|
|
444
|
+
.inner()
|
|
445
|
+
.inspect_container(name, None)
|
|
446
|
+
.await
|
|
447
|
+
.map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
|
|
448
|
+
|
|
449
|
+
let mounts = info.mounts.unwrap_or_default();
|
|
450
|
+
|
|
451
|
+
// Filter to only bind mounts, excluding system paths
|
|
452
|
+
let bind_mounts: Vec<ContainerBindMount> = mounts
|
|
453
|
+
.iter()
|
|
454
|
+
.filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
|
|
455
|
+
.filter(|m| {
|
|
456
|
+
// Exclude system mounts (cgroup, etc.)
|
|
457
|
+
let target = m.destination.as_deref().unwrap_or("");
|
|
458
|
+
!target.starts_with("/sys/")
|
|
459
|
+
})
|
|
460
|
+
.map(|m| ContainerBindMount {
|
|
461
|
+
source: m.source.clone().unwrap_or_default(),
|
|
462
|
+
target: m.destination.clone().unwrap_or_default(),
|
|
463
|
+
read_only: m.rw.map(|rw| !rw).unwrap_or(false),
|
|
464
|
+
})
|
|
465
|
+
.collect();
|
|
466
|
+
|
|
467
|
+
Ok(bind_mounts)
|
|
468
|
+
}
|
|
469
|
+
|
|
359
470
|
#[cfg(test)]
|
|
360
471
|
mod tests {
|
|
361
472
|
use super::*;
|
|
@@ -369,6 +480,6 @@ mod tests {
|
|
|
369
480
|
#[test]
|
|
370
481
|
fn default_image_format() {
|
|
371
482
|
let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
|
|
372
|
-
assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
|
|
483
|
+
assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
|
|
373
484
|
}
|
|
374
485
|
}
|
package/src/docker/dockerfile.rs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
//! Embedded Dockerfile content
|
|
2
2
|
//!
|
|
3
|
-
//! This module contains the Dockerfile for building the opencode-cloud
|
|
4
|
-
//! embedded at compile time for distribution with the CLI.
|
|
3
|
+
//! This module contains the Dockerfile for building the opencode-cloud-sandbox
|
|
4
|
+
//! container image, embedded at compile time for distribution with the CLI.
|
|
5
|
+
//!
|
|
6
|
+
//! Note: The image is named "opencode-cloud-sandbox" (not "opencode-cloud") to
|
|
7
|
+
//! clearly indicate this is the sandboxed container environment that the
|
|
8
|
+
//! opencode-cloud CLI deploys, not the CLI tool itself.
|
|
5
9
|
|
|
6
|
-
/// The Dockerfile for building the opencode-cloud container image
|
|
10
|
+
/// The Dockerfile for building the opencode-cloud-sandbox container image
|
|
7
11
|
pub const DOCKERFILE: &str = include_str!("Dockerfile");
|
|
8
12
|
|
|
9
13
|
// =============================================================================
|
|
@@ -15,27 +19,26 @@ pub const DOCKERFILE: &str = include_str!("Dockerfile");
|
|
|
15
19
|
// - Registry: The server hosting the image (e.g., ghcr.io, gcr.io, docker.io)
|
|
16
20
|
// When omitted, Docker Hub (docker.io) is assumed.
|
|
17
21
|
// - Namespace: Usually the username or organization (e.g., prizz)
|
|
18
|
-
// - Image: The image name (e.g., opencode-cloud)
|
|
22
|
+
// - Image: The image name (e.g., opencode-cloud-sandbox)
|
|
19
23
|
// - Tag: Version identifier (e.g., latest, v1.0.0). Defaults to "latest" if omitted.
|
|
20
24
|
//
|
|
21
25
|
// Examples:
|
|
22
|
-
// ghcr.io/prizz/opencode-cloud:latest - GitHub Container Registry
|
|
23
|
-
// prizz/opencode-cloud:latest - Docker Hub (registry omitted)
|
|
24
|
-
// gcr.io/my-project/myapp:v1.0
|
|
26
|
+
// ghcr.io/prizz/opencode-cloud-sandbox:latest - GitHub Container Registry
|
|
27
|
+
// prizz/opencode-cloud-sandbox:latest - Docker Hub (registry omitted)
|
|
28
|
+
// gcr.io/my-project/myapp:v1.0 - Google Container Registry
|
|
25
29
|
//
|
|
26
|
-
// We
|
|
27
|
-
// Actions for CI/CD publishing.
|
|
30
|
+
// We publish to both GHCR (primary) and Docker Hub for maximum accessibility.
|
|
28
31
|
// =============================================================================
|
|
29
32
|
|
|
30
33
|
/// Docker image name for GitHub Container Registry (primary registry)
|
|
31
34
|
///
|
|
32
35
|
/// Format: `ghcr.io/{github-username}/{image-name}`
|
|
33
|
-
pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud";
|
|
36
|
+
pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud-sandbox";
|
|
34
37
|
|
|
35
|
-
/// Docker image name for Docker Hub (
|
|
38
|
+
/// Docker image name for Docker Hub (secondary registry)
|
|
36
39
|
///
|
|
37
40
|
/// Format: `{dockerhub-username}/{image-name}` (registry prefix omitted for Docker Hub)
|
|
38
|
-
pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud";
|
|
41
|
+
pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud-sandbox";
|
|
39
42
|
|
|
40
43
|
/// Default image tag
|
|
41
44
|
pub const IMAGE_TAG_DEFAULT: &str = "latest";
|
package/src/docker/mod.rs
CHANGED
|
@@ -19,7 +19,9 @@ mod error;
|
|
|
19
19
|
pub mod exec;
|
|
20
20
|
mod health;
|
|
21
21
|
pub mod image;
|
|
22
|
+
pub mod mount;
|
|
22
23
|
pub mod progress;
|
|
24
|
+
pub mod state;
|
|
23
25
|
pub mod update;
|
|
24
26
|
pub mod users;
|
|
25
27
|
mod version;
|
|
@@ -62,12 +64,19 @@ pub use volume::{
|
|
|
62
64
|
VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
// Bind mount parsing and validation
|
|
68
|
+
pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};
|
|
69
|
+
|
|
65
70
|
// Container lifecycle
|
|
66
71
|
pub use container::{
|
|
67
|
-
CONTAINER_NAME,
|
|
68
|
-
|
|
72
|
+
CONTAINER_NAME, ContainerBindMount, ContainerPorts, OPENCODE_WEB_PORT, container_exists,
|
|
73
|
+
container_is_running, container_state, create_container, get_container_bind_mounts,
|
|
74
|
+
get_container_ports, remove_container, start_container, stop_container,
|
|
69
75
|
};
|
|
70
76
|
|
|
77
|
+
// Image state tracking
|
|
78
|
+
pub use state::{ImageState, clear_state, get_state_path, load_state, save_state};
|
|
79
|
+
|
|
71
80
|
/// Full setup: ensure volumes exist, create container if needed, start it
|
|
72
81
|
///
|
|
73
82
|
/// This is the primary entry point for starting the opencode service.
|
|
@@ -80,6 +89,7 @@ pub use container::{
|
|
|
80
89
|
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
|
|
81
90
|
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
|
|
82
91
|
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
|
|
92
|
+
/// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
|
|
83
93
|
pub async fn setup_and_start(
|
|
84
94
|
client: &DockerClient,
|
|
85
95
|
opencode_web_port: Option<u16>,
|
|
@@ -87,6 +97,7 @@ pub async fn setup_and_start(
|
|
|
87
97
|
bind_address: Option<&str>,
|
|
88
98
|
cockpit_port: Option<u16>,
|
|
89
99
|
cockpit_enabled: Option<bool>,
|
|
100
|
+
bind_mounts: Option<Vec<mount::ParsedMount>>,
|
|
90
101
|
) -> Result<String, DockerError> {
|
|
91
102
|
// Ensure volumes exist first
|
|
92
103
|
volume::ensure_volumes_exist(client).await?;
|
|
@@ -114,6 +125,7 @@ pub async fn setup_and_start(
|
|
|
114
125
|
bind_address,
|
|
115
126
|
cockpit_port,
|
|
116
127
|
cockpit_enabled,
|
|
128
|
+
bind_mounts,
|
|
117
129
|
)
|
|
118
130
|
.await?
|
|
119
131
|
};
|
|
@@ -126,13 +138,22 @@ pub async fn setup_and_start(
|
|
|
126
138
|
Ok(container_id)
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
/// Default graceful shutdown timeout in seconds
|
|
142
|
+
pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
|
|
143
|
+
|
|
129
144
|
/// Stop and optionally remove the opencode container
|
|
130
145
|
///
|
|
131
146
|
/// # Arguments
|
|
132
147
|
/// * `client` - Docker client
|
|
133
148
|
/// * `remove` - Also remove the container after stopping
|
|
134
|
-
|
|
149
|
+
/// * `timeout_secs` - Graceful shutdown timeout (default: 30 seconds)
|
|
150
|
+
pub async fn stop_service(
|
|
151
|
+
client: &DockerClient,
|
|
152
|
+
remove: bool,
|
|
153
|
+
timeout_secs: Option<i64>,
|
|
154
|
+
) -> Result<(), DockerError> {
|
|
135
155
|
let name = container::CONTAINER_NAME;
|
|
156
|
+
let timeout = timeout_secs.unwrap_or(DEFAULT_STOP_TIMEOUT_SECS);
|
|
136
157
|
|
|
137
158
|
// Check if container exists
|
|
138
159
|
if !container::container_exists(client, name).await? {
|
|
@@ -143,7 +164,7 @@ pub async fn stop_service(client: &DockerClient, remove: bool) -> Result<(), Doc
|
|
|
143
164
|
|
|
144
165
|
// Stop if running
|
|
145
166
|
if container::container_is_running(client, name).await? {
|
|
146
|
-
container::stop_container(client, name,
|
|
167
|
+
container::stop_container(client, name, Some(timeout)).await?;
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
// Remove if requested
|
|
@@ -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
|
+
}
|