Skip to main content

nucleus/filesystem/
gpu.rs

1//! GPU passthrough device discovery and filesystem binding.
2//!
3//! This module resolves which host GPU device nodes (and the minimal driver
4//! support files they require) should be exposed to a container, and performs
5//! the bind mounts that expose them. The cgroup device allowlist and seccomp
6//! relaxation live in [`crate::resources::cgroup`] and [`crate::security`]
7//! respectively; this module only owns the *which devices* and *mount them*
8//! concerns.
9//!
10//! See `spec/gpu-passthrough.md` for the full design.
11
12use std::collections::HashSet;
13use std::fs;
14use std::os::unix::fs::FileTypeExt;
15use std::os::unix::fs::MetadataExt;
16use std::os::unix::fs::PermissionsExt;
17use std::path::{Path, PathBuf};
18
19use tracing::{debug, info, warn};
20
21use crate::container::{GpuPassthroughConfig, GpuVendor, ProcessIdentity};
22use crate::error::{NucleusError, Result};
23
24/// NVIDIA device-node glob patterns (regex-free) scanned under the host `/dev`.
25const NVIDIA_DEVICE_NAMES: &[&str] = &[
26    "nvidiactl",
27    "nvidia-uvm",
28    "nvidia-uvm-tools",
29];
30
31/// Directory holding NVIDIA capability device nodes on newer drivers.
32const NVIDIA_CAPS_DIR: &str = "nvidia-caps";
33
34/// A resolved set of host GPU device nodes plus the vendor flags needed by the
35/// rest of the runtime (env vars, support-file selection).
36#[derive(Debug, Clone, Default)]
37pub struct GpuDeviceSet {
38    /// Canonical host device node paths to bind into the container `/dev`.
39    pub nodes: Vec<PathBuf>,
40    pub nvidia: bool,
41    pub amd: bool,
42    pub intel: bool,
43}
44
45impl GpuDeviceSet {
46    /// Number of devices that will be exposed.
47    pub fn len(&self) -> usize {
48        self.nodes.len()
49    }
50
51    /// Whether any GPU device was resolved.
52    pub fn is_empty(&self) -> bool {
53        self.nodes.is_empty()
54    }
55
56    /// Major/minor/device-kind for every node, for the cgroup device BPF.
57    ///
58    /// Nodes that cannot be stat'd are skipped (with a debug log); the cgroup
59    /// allowlist is best-effort and the filesystem layer remains the primary
60    /// gate.
61    pub fn device_specs(&self) -> Vec<DeviceNodeSpec> {
62        self.node_specs_with_paths().iter().map(|(_, s)| *s).collect()
63    }
64
65    /// Each node paired with its host path, for OCI device entries and the
66    /// cgroup device BPF.
67    pub fn node_specs_with_paths(&self) -> Vec<(PathBuf, DeviceNodeSpec)> {
68        let mut out = Vec::with_capacity(self.nodes.len());
69        for node in &self.nodes {
70            match fs::metadata(node) {
71                Ok(meta) => {
72                    let rdev = meta.rdev();
73                    out.push((
74                        node.clone(),
75                        DeviceNodeSpec {
76                            is_block: meta.file_type().is_block_device(),
77                            major: major_of(rdev),
78                            minor: minor_of(rdev),
79                        },
80                    ));
81                }
82                Err(e) => debug!("cannot stat GPU device {:?}: {}", node, e),
83            }
84        }
85        out
86    }
87}
88
89/// A single device node's identity for the cgroup device allowlist.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct DeviceNodeSpec {
92    pub is_block: bool,
93    pub major: u32,
94    pub minor: u32,
95}
96
97/// Extract the major number from a `st_rdev` value (Linux `MAJOR` macro).
98pub fn major_of(rdev: u64) -> u32 {
99    // MAJOR(dev) = ((dev >> 8) & 0xfff) | ((dev >> 32) & 0xfffff000)
100    (((rdev >> 8) & 0xfff) | ((rdev >> 32) & 0xfffff000)) as u32
101}
102
103/// Extract the minor number from a `st_rdev` value (Linux `MINOR` macro).
104pub fn minor_of(rdev: u64) -> u32 {
105    // MINOR(dev) = (dev & 0xff) | ((dev >> 12) & 0xfff00)
106    ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u32
107}
108
109/// Resolve the GPU device set for a configuration.
110///
111/// When `config.devices` is non-empty, those explicit host paths are validated
112/// and used verbatim (deduplicated, sorted). Otherwise the host `/dev` is
113/// scanned according to `config.vendor`.
114///
115/// Returns `Ok(None)` when no GPU devices are present and none were requested
116/// explicitly — callers decide whether that is an error.
117pub fn resolve_gpu_devices(config: &GpuPassthroughConfig) -> Result<Option<GpuDeviceSet>> {
118    if !config.devices.is_empty() {
119        return resolve_explicit(&config.devices, config.vendor);
120    }
121    discover_gpu_at(Path::new("/dev"), config.vendor)
122}
123
124/// Resolve an explicitly-provided device list.
125fn resolve_explicit(devices: &[PathBuf], vendor: GpuVendor) -> Result<Option<GpuDeviceSet>> {
126    let mut canonical: Vec<PathBuf> = Vec::with_capacity(devices.len());
127    for dev in devices {
128        canonical.push(validate_host_device(dev)?);
129    }
130    Ok(build_explicit_set(&canonical, vendor))
131}
132
133/// Build a [`GpuDeviceSet`] from already-validated canonical device paths.
134///
135/// Separated from [`resolve_explicit`] so the dedup/sort/classify logic is
136/// unit-testable without real device nodes (which require root to create).
137pub(crate) fn build_explicit_set(canonical: &[PathBuf], vendor: GpuVendor) -> Option<GpuDeviceSet> {
138    let mut set = GpuDeviceSet::default();
139    let mut seen: HashSet<PathBuf> = HashSet::new();
140    for dev in canonical {
141        if !seen.insert(dev.clone()) {
142            debug!("ignoring duplicate GPU device {:?}", dev);
143            continue;
144        }
145        classify_into(dev, vendor, &mut set);
146        set.nodes.push(dev.clone());
147    }
148    set.nodes.sort();
149    if set.nodes.is_empty() {
150        None
151    } else {
152        Some(set)
153    }
154}
155
156/// Validate that `path` is an existing, non-symlink device node on the host.
157fn validate_host_device(path: &Path) -> Result<PathBuf> {
158    // Reject obvious traversal before canonicalizing.
159    let canonical = fs::canonicalize(path).map_err(|e| {
160        NucleusError::ConfigError(format!(
161            "GPU device '{}' does not exist or cannot be resolved: {}",
162            path.display(),
163            e
164        ))
165    })?;
166    let meta = fs::symlink_metadata(&canonical)
167        .map_err(|e| NucleusError::ConfigError(format!("Failed to stat GPU device '{}': {}", canonical.display(), e)))?;
168    if meta.file_type().is_symlink() {
169        return Err(NucleusError::ConfigError(format!(
170            "GPU device '{}' must not be a symlink",
171            canonical.display()
172        )));
173    }
174    if !meta.file_type().is_char_device() && !meta.file_type().is_block_device() {
175        return Err(NucleusError::ConfigError(format!(
176            "GPU device '{}' is not a device node",
177            canonical.display()
178        )));
179    }
180    Ok(canonical)
181}
182
183/// Discover GPU device nodes under `dev_root` (normally `/dev`).
184///
185/// Exposed separately from [`resolve_gpu_devices`] so it can be unit-tested
186/// against a temporary `/dev` tree without root.
187pub fn discover_gpu_at(dev_root: &Path, vendor: GpuVendor) -> Result<Option<GpuDeviceSet>> {
188    discover_gpu_with(dev_root, vendor, is_char_device)
189}
190
191/// Discovery core with an injectable device validator.
192///
193/// The validator lets tests scan a tempdir of regular files; production paths
194/// pass [`is_char_device`].
195pub(crate) fn discover_gpu_with(
196    dev_root: &Path,
197    vendor: GpuVendor,
198    is_dev: impl Fn(&Path) -> bool,
199) -> Result<Option<GpuDeviceSet>> {
200    let mut set = GpuDeviceSet::default();
201
202    if vendor.includes_nvidia() {
203        collect_nvidia(dev_root, vendor, &mut set, &is_dev)?;
204    }
205    if vendor.includes_amd() {
206        collect_amd(dev_root, vendor, &mut set, &is_dev);
207    }
208    if vendor.includes_intel() {
209        collect_intel(dev_root, vendor, &mut set, &is_dev);
210    }
211
212    if set.nodes.is_empty() {
213        return Ok(None);
214    }
215
216    // Dedup (render nodes are shared between AMD/Intel) and sort for determinism.
217    let mut deduped: Vec<PathBuf> = set.nodes.into_iter().collect::<HashSet<_>>().into_iter().collect();
218    deduped.sort();
219    set.nodes = deduped;
220    Ok(Some(set))
221}
222
223fn collect_nvidia(
224    dev_root: &Path,
225    vendor: GpuVendor,
226    set: &mut GpuDeviceSet,
227    is_dev: &impl Fn(&Path) -> bool,
228) -> Result<()> {
229    // /dev/nvidia[0-9]+
230    let mut found = false;
231    if let Ok(entries) = fs::read_dir(dev_root) {
232        for entry in entries.flatten() {
233            let name = entry.file_name();
234            let name = name.to_string_lossy();
235            if let Some(rest) = name.strip_prefix("nvidia") {
236                if !rest.is_empty()
237                    && rest.chars().all(|c| c.is_ascii_digit())
238                    && is_dev(&entry.path())
239                {
240                    push_existing(dev_root, &name, set, is_dev);
241                    found = true;
242                }
243            }
244        }
245    }
246
247    for fixed in NVIDIA_DEVICE_NAMES {
248        push_existing(dev_root, fixed, set, is_dev);
249    }
250
251    // /dev/nvidia-caps/* (capability device nodes on newer drivers).
252    let caps = dev_root.join(NVIDIA_CAPS_DIR);
253    if caps.is_dir() {
254        if let Ok(entries) = fs::read_dir(&caps) {
255            for entry in entries.flatten() {
256                let path = entry.path();
257                if is_dev(&path) {
258                    if let Ok(canonical) = fs::canonicalize(&path) {
259                        set.nodes.push(canonical);
260                    }
261                }
262            }
263        }
264    }
265
266    set.nvidia = vendor.includes_nvidia()
267        && (found
268            || set
269                .nodes
270                .iter()
271                .any(|p| p.file_name().map(|f| f.to_string_lossy().starts_with("nvidia")).unwrap_or(false)));
272    Ok(())
273}
274
275fn collect_amd(dev_root: &Path, vendor: GpuVendor, set: &mut GpuDeviceSet, is_dev: &impl Fn(&Path) -> bool) {
276    let had_before = set.nodes.len();
277    push_existing(dev_root, "kfd", set, is_dev);
278    collect_render_nodes(dev_root, set, is_dev);
279    if set.nodes.len() > had_before {
280        set.amd = vendor.includes_amd();
281    }
282}
283
284fn collect_intel(dev_root: &Path, vendor: GpuVendor, set: &mut GpuDeviceSet, is_dev: &impl Fn(&Path) -> bool) {
285    let had_before = set.nodes.len();
286    collect_render_nodes(dev_root, set, is_dev);
287    // /dev/dri/card[0-9]+ are the kernel KMS nodes; bind them so Mesa/DRI works.
288    if let Ok(entries) = fs::read_dir(dev_root.join("dri")) {
289        for entry in entries.flatten() {
290            let name = entry.file_name();
291            let name = name.to_string_lossy();
292            if let Some(rest) = name.strip_prefix("card") {
293                if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
294                    push_under_dir(&dev_root.join("dri"), &name, set, is_dev);
295                }
296            }
297        }
298    }
299    if set.nodes.len() > had_before {
300        set.intel = vendor.includes_intel();
301    }
302}
303
304/// Collect `/dev/dri/renderD[0-9]+` (V3D/AMD/Intel render nodes).
305fn collect_render_nodes(dev_root: &Path, set: &mut GpuDeviceSet, is_dev: &impl Fn(&Path) -> bool) {
306    let dri = dev_root.join("dri");
307    if let Ok(entries) = fs::read_dir(&dri) {
308        for entry in entries.flatten() {
309            let name = entry.file_name();
310            let name = name.to_string_lossy();
311            if let Some(rest) = name.strip_prefix("renderD") {
312                if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
313                    push_under_dir(&dri, &name, set, is_dev);
314                }
315            }
316        }
317    }
318}
319
320fn push_existing(dev_root: &Path, name: &str, set: &mut GpuDeviceSet, is_dev: &impl Fn(&Path) -> bool) {
321    let path = dev_root.join(name);
322    if is_dev(&path) {
323        if let Ok(canonical) = fs::canonicalize(&path) {
324            set.nodes.push(canonical);
325        }
326    }
327}
328
329fn push_under_dir(dir: &Path, name: &str, set: &mut GpuDeviceSet, is_dev: &impl Fn(&Path) -> bool) {
330    let path = dir.join(name);
331    if is_dev(&path) {
332        if let Ok(canonical) = fs::canonicalize(&path) {
333            set.nodes.push(canonical);
334        }
335    }
336}
337
338fn is_char_device(path: &Path) -> bool {
339    fs::metadata(path)
340        .map(|m| m.file_type().is_char_device())
341        .unwrap_or(false)
342}
343
344/// Classify an explicit device into vendor flags by its canonical name.
345fn classify_into(path: &Path, _vendor: GpuVendor, set: &mut GpuDeviceSet) {
346    let name = path
347        .file_name()
348        .map(|f| f.to_string_lossy().to_string())
349        .unwrap_or_default();
350    if name.starts_with("nvidia") {
351        set.nvidia = true;
352    } else if name == "kfd" {
353        set.amd = true;
354    } else if name.starts_with("renderD") || name.starts_with("card") {
355        // Ambiguous between AMD/Intel; mark both so env/support logic is permissive.
356        set.amd = true;
357        set.intel = true;
358    }
359}
360
361/// Candidate host support files for the resolved vendor set.
362///
363/// Only paths that actually exist on the host are returned, so callers can
364/// bind them unconditionally and simply skip missing ones.
365pub fn support_paths(set: &GpuDeviceSet, bind_driver_libs: bool) -> Vec<PathBuf> {
366    let mut paths = Vec::new();
367
368    if set.nvidia {
369        let proc_driver = Path::new("/proc/driver/nvidia");
370        if proc_driver.is_dir() {
371            paths.push(proc_driver.to_path_buf());
372        }
373        if bind_driver_libs {
374            // NVIDIA driver userspace libraries and the CUDA toolkit, in the
375            // standard Debian/Ubuntu and runfile locations. Bound read-only.
376            for lib_dir in [
377                "/usr/lib/x86_64-linux-gnu",
378                "/usr/lib64",
379                "/opt/nvidia",
380                "/usr/local/cuda",
381            ] {
382                let dir = Path::new(lib_dir);
383                if dir.is_dir() && dir_contains_nvidia_libs(dir) {
384                    paths.push(dir.to_path_buf());
385                }
386            }
387            // Vulkan/ICD manifest JSON so the container's loader finds the host driver.
388            for icd in [
389                "/etc/vulkan/icd.d",
390                "/usr/share/vulkan/icd.d",
391                "/etc/glvnd/egl_vendor.d",
392                "/usr/share/glvnd/egl_vendor.d",
393            ] {
394                let dir = Path::new(icd);
395                if dir.is_dir() && dir_contains_json(dir) {
396                    paths.push(dir.to_path_buf());
397                }
398            }
399        }
400    }
401
402    if set.amd && bind_driver_libs {
403        for dir in ["/opt/rocm", "/opt/amdgpu"] {
404            let d = Path::new(dir);
405            if d.is_dir() {
406                paths.push(d.to_path_buf());
407            }
408        }
409    }
410
411    paths.sort();
412    paths.dedup();
413    paths
414}
415
416fn dir_contains_nvidia_libs(dir: &Path) -> bool {
417    let Ok(entries) = fs::read_dir(dir) else {
418        return false;
419    };
420    for entry in entries.flatten() {
421        let name = entry.file_name();
422        let name = name.to_string_lossy();
423        if name.starts_with("libnvidia") || name.starts_with("libcuda") {
424            return true;
425        }
426    }
427    false
428}
429
430fn dir_contains_json(dir: &Path) -> bool {
431    let Ok(entries) = fs::read_dir(dir) else {
432        return false;
433    };
434    entries.flatten()
435        .any(|e| e.file_name().to_string_lossy().ends_with(".json"))
436}
437
438/// Result of binding GPU devices into a container root.
439#[derive(Debug, Clone, Default)]
440pub struct GpuMountResult {
441    /// Device nodes bound (host -> container-relative under /dev).
442    pub bound_devices: Vec<(PathBuf, PathBuf)>,
443    /// Support files/dirs bound.
444    pub bound_support: Vec<PathBuf>,
445    /// Whether the bind was performed read-only for support files.
446    pub nvidia: bool,
447    pub amd: bool,
448    pub intel: bool,
449}
450
451/// Bind-mount the resolved GPU devices and support files into `root`.
452///
453/// Device nodes are bound under `root/dev/...` preserving their host path so
454/// libraries that hardcode `/dev/nvidia0` continue to work. Each node is
455/// chown'd to the workload identity so a non-root workload can open it, and
456/// left mode 0660.
457///
458/// This runs in the child after `create_dev_nodes` and before `pivot_root`.
459pub fn mount_gpu_passthrough(
460    root: &Path,
461    set: &GpuDeviceSet,
462    config: &GpuPassthroughConfig,
463    identity: &ProcessIdentity,
464) -> Result<GpuMountResult> {
465    use nix::mount::{mount, MsFlags};
466    use nix::unistd::{chown, Gid, Uid};
467
468    let mut result = GpuMountResult {
469        nvidia: set.nvidia,
470        amd: set.amd,
471        intel: set.intel,
472        ..Default::default()
473    };
474
475    let dev_path = root.join("dev");
476    std::fs::create_dir_all(&dev_path).map_err(|e| {
477        NucleusError::FilesystemError(format!("Failed to create container /dev: {}", e))
478    })?;
479
480    let bind_flags = MsFlags::MS_BIND | MsFlags::MS_REC;
481
482    for host_node in &set.nodes {
483        // Mirror the host path under the container /dev.
484        let rel = host_node
485            .strip_prefix("/")
486            .unwrap_or(host_node.as_path());
487        let target = dev_path.join(rel);
488        if let Some(parent) = target.parent() {
489            std::fs::create_dir_all(parent).map_err(|e| {
490                NucleusError::FilesystemError(format!(
491                    "Failed to create device parent dir {:?}: {}",
492                    parent, e
493                ))
494            })?;
495        }
496
497        // Create a placeholder node so the bind mount has a mountpoint. Use
498        // mknod of a char device (best effort; rootful path).
499        let _ = create_placeholder_char_node(&target);
500
501        mount(
502            Some(host_node),
503            &target,
504            None::<&str>,
505            bind_flags,
506            None::<&str>,
507        )
508        .map_err(|e| {
509            NucleusError::FilesystemError(format!(
510                "Failed to bind GPU device {:?} -> {:?}: {}",
511                host_node, target, e
512            ))
513        })?;
514
515        // Make the node usable by the (possibly non-root) workload identity.
516        let gid = if identity.gid != 0 {
517            Some(Gid::from_raw(identity.gid))
518        } else {
519            None
520        };
521        let uid = if identity.uid != 0 {
522            Some(Uid::from_raw(identity.uid))
523        } else {
524            None
525        };
526        let _ = chown(&target, uid, gid);
527        let _ = std::fs::set_permissions(
528            &target,
529            std::fs::Permissions::from_mode(0o660),
530        );
531
532        result
533            .bound_devices
534            .push((host_node.clone(), target.strip_prefix(root).unwrap_or(&target).to_path_buf()));
535        info!("Bound GPU device {:?} -> /dev/{}", host_node, rel.display());
536    }
537
538    // Driver support files (NVIDIA /proc, lib dirs, ICD JSON; ROCm /opt/rocm).
539    for support in support_paths(set, config.bind_driver_libraries) {
540        let rel = support.strip_prefix("/").unwrap_or(support.as_path());
541        let target = root.join(rel);
542        if let Some(parent) = target.parent() {
543            let _ = std::fs::create_dir_all(parent);
544        }
545        if support.is_dir() {
546            let _ = std::fs::create_dir_all(&target);
547        } else {
548            // Create a placeholder regular file so the bind has a mountpoint.
549            let _ = std::fs::OpenOptions::new()
550                .create(true)
551                .write(true)
552                .truncate(true)
553                .open(&target);
554        }
555
556        mount(
557            Some(&support),
558            &target,
559            None::<&str>,
560            bind_flags,
561            None::<&str>,
562        )
563        .map_err(|e| {
564            NucleusError::FilesystemError(format!(
565                "Failed to bind GPU support {:?} -> {:?}: {}",
566                support, target, e
567            ))
568        })?;
569
570        // Remount read-only: device drivers and ICD manifests are read-only inputs.
571        mount(
572            None::<&str>,
573            &target,
574            None::<&str>,
575            bind_flags | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY,
576            None::<&str>,
577        )
578        .map_err(|e| {
579            // Read-only remount is best-effort: some NVIDIA proc files are
580            // writable by the driver; warn but keep the rw bind.
581            warn!(
582                "Failed to remount GPU support {:?} read-only: {} (leaving rw)",
583                target, e
584            );
585            NucleusError::FilesystemError(format!("read-only remount failed: {}", e))
586        })
587        .ok();
588
589        result
590            .bound_support
591            .push(target.strip_prefix(root).unwrap_or(&target).to_path_buf());
592        debug!("Bound GPU support {:?}", support);
593    }
594
595    Ok(result)
596}
597
598fn create_placeholder_char_node(target: &Path) -> Result<()> {
599    use nix::sys::stat::{makedev, mknod, Mode, SFlag};
600    let dev = makedev(0, 0);
601    match mknod(target, SFlag::S_IFCHR, Mode::from_bits_truncate(0o600), dev) {
602        Ok(_) => Ok(()),
603        Err(nix::Error::EEXIST) => Ok(()),
604        Err(e) => {
605            debug!("placeholder mknod for {:?} failed (continuing): {}", target, e);
606            Ok(())
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests;