Skip to content

Commit 9372e33

Browse files
committed
feat(docker,podman): add SELinux label support for bind mounts
The Docker Engine structured Mount API does not support SELinux relabelling (:z / :Z). Move user-supplied bind mounts from the structured `mounts` field to the legacy string-format `binds` field, which does support these options. Add a shared `SelinuxLabel` enum (shared/private) to openshell-core so both Docker and Podman drivers accept an optional `selinux_label` field on bind mount configs. For Docker, labels are appended to the bind string; for Podman, they are pushed to the mount options vec. Signed-off-by: Florian Bergmann <fbergman@redhat.com>
1 parent 914da33 commit 9372e33

5 files changed

Lines changed: 318 additions & 37 deletions

File tree

crates/openshell-core/src/driver_mounts.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55
66
use std::path::Path;
77

8+
/// `SELinux` relabelling mode for bind mounts.
9+
///
10+
/// On hosts with `SELinux` enabled (e.g. Fedora, RHEL) a bind-mounted path
11+
/// must be relabelled so the container process can access it.
12+
///
13+
/// * `shared` (`:z`) — the label is shared across all containers that mount
14+
/// the same path. Safe when multiple sandboxes read the same data set.
15+
/// * `private` (`:Z`) — the label is private to *this* container. The host
16+
/// directory becomes inaccessible to other containers (and potentially to
17+
/// the host) until the container is removed. Use only when exclusive
18+
/// ownership is acceptable.
19+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
20+
#[serde(rename_all = "snake_case")]
21+
pub enum SelinuxLabel {
22+
/// Shared `SELinux` label (`:z`).
23+
Shared,
24+
/// Private `SELinux` label (`:Z`).
25+
Private,
26+
}
27+
828
const RESERVED_MOUNT_TARGETS: &[&str] = &[
929
"/opt/openshell",
1030
"/etc/openshell",

crates/openshell-driver-docker/src/lib.rs

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ impl DockerSandboxDriverConfig {
303303
}
304304
}
305305

306+
use openshell_core::driver_mounts::SelinuxLabel;
307+
306308
#[derive(Debug, Clone, serde::Deserialize)]
307309
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
308310
enum DockerDriverMountConfig {
@@ -311,6 +313,8 @@ enum DockerDriverMountConfig {
311313
target: String,
312314
#[serde(default = "default_true")]
313315
read_only: bool,
316+
#[serde(default)]
317+
selinux_label: Option<SelinuxLabel>,
314318
},
315319
Volume {
316320
source: String,
@@ -1763,29 +1767,76 @@ fn docker_driver_config(
17631767
Ok(config)
17641768
}
17651769

1770+
/// Collect user-supplied bind mounts as string-format binds.
1771+
///
1772+
/// Bind mounts use the legacy `Binds` field (`-v` syntax) rather than the
1773+
/// structured `Mount` API because the Docker Engine Mount object does not
1774+
/// support `SELinux` relabelling (`:z` / `:Z`). The string format does.
1775+
fn docker_driver_bind_strings(config: &DockerSandboxDriverConfig) -> Result<Vec<String>, Status> {
1776+
config
1777+
.mounts
1778+
.iter()
1779+
.filter_map(|m| match m {
1780+
DockerDriverMountConfig::Bind {
1781+
source,
1782+
target,
1783+
read_only,
1784+
selinux_label,
1785+
} => Some(docker_bind_string(
1786+
source,
1787+
target,
1788+
*read_only,
1789+
*selinux_label,
1790+
)),
1791+
_ => None,
1792+
})
1793+
.collect()
1794+
}
1795+
1796+
fn docker_bind_string(
1797+
source: &str,
1798+
target: &str,
1799+
read_only: bool,
1800+
selinux_label: Option<SelinuxLabel>,
1801+
) -> Result<String, Status> {
1802+
let source = driver_mounts::validate_absolute_mount_source(source, "bind source")
1803+
.map_err(Status::failed_precondition)?;
1804+
let target = driver_mounts::validate_container_mount_target(target)
1805+
.map_err(Status::failed_precondition)?;
1806+
1807+
let mut opts = Vec::new();
1808+
if read_only {
1809+
opts.push("ro");
1810+
}
1811+
match selinux_label {
1812+
Some(SelinuxLabel::Shared) => opts.push("z"),
1813+
Some(SelinuxLabel::Private) => opts.push("Z"),
1814+
None => {}
1815+
}
1816+
1817+
if opts.is_empty() {
1818+
Ok(format!("{source}:{target}"))
1819+
} else {
1820+
Ok(format!("{source}:{target}:{}", opts.join(",")))
1821+
}
1822+
}
1823+
1824+
/// Collect user-supplied non-bind mounts as structured `Mount` objects.
17661825
fn docker_driver_mounts(config: &DockerSandboxDriverConfig) -> Result<Vec<Mount>, Status> {
1767-
config.mounts.iter().map(docker_mount_from_config).collect()
1826+
config
1827+
.mounts
1828+
.iter()
1829+
.filter(|m| !matches!(m, DockerDriverMountConfig::Bind { .. }))
1830+
.map(docker_mount_from_config)
1831+
.collect()
17681832
}
17691833

17701834
fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result<Mount, Status> {
17711835
match config {
1772-
DockerDriverMountConfig::Bind {
1773-
source,
1774-
target,
1775-
read_only,
1776-
} => Ok(Mount {
1777-
typ: Some(MountTypeEnum::BIND),
1778-
source: Some(
1779-
driver_mounts::validate_absolute_mount_source(source, "bind source")
1780-
.map_err(Status::failed_precondition)?,
1781-
),
1782-
target: Some(
1783-
driver_mounts::validate_container_mount_target(target)
1784-
.map_err(Status::failed_precondition)?,
1785-
),
1786-
read_only: Some(*read_only),
1787-
..Default::default()
1788-
}),
1836+
DockerDriverMountConfig::Bind { .. } => {
1837+
// Bind mounts are handled via docker_driver_bind_strings.
1838+
unreachable!("bind mounts are filtered out before reaching docker_mount_from_config")
1839+
}
17891840
DockerDriverMountConfig::Volume {
17901841
source,
17911842
target,
@@ -2285,6 +2336,7 @@ fn build_container_create_body_with_gpu_devices(
22852336
.ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?;
22862337
let resource_limits = docker_resource_limits(template)?;
22872338
let user_mounts = docker_driver_mounts(driver_config)?;
2339+
let user_bind_strings = docker_driver_bind_strings(driver_config)?;
22882340
let device_requests = gpu_device_ids.map(|device_ids| {
22892341
vec![DeviceRequest {
22902342
driver: Some("cdi".to_string()),
@@ -2322,7 +2374,11 @@ fn build_container_create_body_with_gpu_devices(
23222374
memory: resource_limits.memory_bytes,
23232375
pids_limit: docker_pids_limit(config.sandbox_pids_limit)?,
23242376
device_requests,
2325-
binds: Some(build_binds(sandbox, config)?),
2377+
binds: {
2378+
let mut binds = build_binds(sandbox, config)?;
2379+
binds.extend(user_bind_strings);
2380+
Some(binds)
2381+
},
23262382
mounts: Some(user_mounts),
23272383
restart_policy: Some(RestartPolicy {
23282384
name: Some(RestartPolicyNameEnum::UNLESS_STOPPED),

crates/openshell-driver-docker/src/tests.rs

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -801,17 +801,25 @@ fn build_container_create_body_includes_bind_mounts_when_enabled() {
801801
config.enable_bind_mounts = true;
802802

803803
let body = build_container_create_body(&sandbox, &config).unwrap();
804-
let mounts = body
804+
let binds = body
805805
.host_config
806+
.as_ref()
806807
.unwrap()
807-
.mounts
808-
.expect("driver config mounts should be set");
808+
.binds
809+
.as_ref()
810+
.expect("binds should be set");
809811

810-
assert_eq!(mounts.len(), 1);
811-
assert_eq!(mounts[0].typ, Some(MountTypeEnum::BIND));
812-
assert_eq!(mounts[0].source.as_deref(), Some("/host/path"));
813-
assert_eq!(mounts[0].target.as_deref(), Some("/sandbox/host"));
814-
assert_eq!(mounts[0].read_only, Some(true));
812+
// User bind mount appears after the system binds.
813+
assert!(
814+
binds.iter().any(|b| b == "/host/path:/sandbox/host:ro"),
815+
"expected bind entry '/host/path:/sandbox/host:ro', got {binds:?}"
816+
);
817+
// Bind mounts must not appear in the structured mounts vec.
818+
let mounts = body.host_config.unwrap().mounts.unwrap_or_default();
819+
assert!(
820+
mounts.iter().all(|m| m.typ != Some(MountTypeEnum::BIND)),
821+
"bind mounts should not appear in structured mounts"
822+
);
815823
}
816824

817825
#[test]
@@ -835,13 +843,122 @@ fn driver_config_defaults_enabled_bind_mounts_to_read_only() {
835843
config.enable_bind_mounts = true;
836844

837845
let body = build_container_create_body(&sandbox, &config).unwrap();
838-
let mounts = body
846+
let binds = body
839847
.host_config
840848
.unwrap()
841-
.mounts
842-
.expect("driver config mounts should be set");
849+
.binds
850+
.expect("binds should be set");
843851

844-
assert_eq!(mounts[0].read_only, Some(true));
852+
assert!(
853+
binds
854+
.iter()
855+
.any(|b| b.contains("/host/path:/sandbox/host:ro")),
856+
"default bind mount should be read-only, got {binds:?}"
857+
);
858+
}
859+
860+
#[test]
861+
fn bind_mount_selinux_shared_label() {
862+
let mut sandbox = test_sandbox();
863+
sandbox
864+
.spec
865+
.as_mut()
866+
.unwrap()
867+
.template
868+
.as_mut()
869+
.unwrap()
870+
.driver_config = Some(json_struct(serde_json::json!({
871+
"mounts": [{
872+
"type": "bind",
873+
"source": "/data/shared",
874+
"target": "/sandbox/data",
875+
"read_only": true,
876+
"selinux_label": "shared"
877+
}]
878+
})));
879+
let mut config = runtime_config();
880+
config.enable_bind_mounts = true;
881+
882+
let body = build_container_create_body(&sandbox, &config).unwrap();
883+
let binds = body
884+
.host_config
885+
.unwrap()
886+
.binds
887+
.expect("binds should be set");
888+
889+
assert!(
890+
binds.iter().any(|b| b == "/data/shared:/sandbox/data:ro,z"),
891+
"expected ':ro,z' label, got {binds:?}"
892+
);
893+
}
894+
895+
#[test]
896+
fn bind_mount_selinux_private_label() {
897+
let mut sandbox = test_sandbox();
898+
sandbox
899+
.spec
900+
.as_mut()
901+
.unwrap()
902+
.template
903+
.as_mut()
904+
.unwrap()
905+
.driver_config = Some(json_struct(serde_json::json!({
906+
"mounts": [{
907+
"type": "bind",
908+
"source": "/data/exclusive",
909+
"target": "/sandbox/data",
910+
"read_only": false,
911+
"selinux_label": "private"
912+
}]
913+
})));
914+
let mut config = runtime_config();
915+
config.enable_bind_mounts = true;
916+
917+
let body = build_container_create_body(&sandbox, &config).unwrap();
918+
let binds = body
919+
.host_config
920+
.unwrap()
921+
.binds
922+
.expect("binds should be set");
923+
924+
assert!(
925+
binds.iter().any(|b| b == "/data/exclusive:/sandbox/data:Z"),
926+
"expected ':Z' label, got {binds:?}"
927+
);
928+
}
929+
930+
#[test]
931+
fn bind_mount_without_selinux_label() {
932+
let mut sandbox = test_sandbox();
933+
sandbox
934+
.spec
935+
.as_mut()
936+
.unwrap()
937+
.template
938+
.as_mut()
939+
.unwrap()
940+
.driver_config = Some(json_struct(serde_json::json!({
941+
"mounts": [{
942+
"type": "bind",
943+
"source": "/host/path",
944+
"target": "/sandbox/host",
945+
"read_only": false
946+
}]
947+
})));
948+
let mut config = runtime_config();
949+
config.enable_bind_mounts = true;
950+
951+
let body = build_container_create_body(&sandbox, &config).unwrap();
952+
let binds = body
953+
.host_config
954+
.unwrap()
955+
.binds
956+
.expect("binds should be set");
957+
958+
assert!(
959+
binds.iter().any(|b| b == "/host/path:/sandbox/host"),
960+
"expected no options suffix, got {binds:?}"
961+
);
845962
}
846963

847964
#[test]

0 commit comments

Comments
 (0)