1use 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
24const NVIDIA_DEVICE_NAMES: &[&str] = &[
26 "nvidiactl",
27 "nvidia-uvm",
28 "nvidia-uvm-tools",
29];
30
31const NVIDIA_CAPS_DIR: &str = "nvidia-caps";
33
34#[derive(Debug, Clone, Default)]
37pub struct GpuDeviceSet {
38 pub nodes: Vec<PathBuf>,
40 pub nvidia: bool,
41 pub amd: bool,
42 pub intel: bool,
43}
44
45impl GpuDeviceSet {
46 pub fn len(&self) -> usize {
48 self.nodes.len()
49 }
50
51 pub fn is_empty(&self) -> bool {
53 self.nodes.is_empty()
54 }
55
56 pub fn device_specs(&self) -> Vec<DeviceNodeSpec> {
62 self.node_specs_with_paths().iter().map(|(_, s)| *s).collect()
63 }
64
65 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct DeviceNodeSpec {
92 pub is_block: bool,
93 pub major: u32,
94 pub minor: u32,
95}
96
97pub fn major_of(rdev: u64) -> u32 {
99 (((rdev >> 8) & 0xfff) | ((rdev >> 32) & 0xfffff000)) as u32
101}
102
103pub fn minor_of(rdev: u64) -> u32 {
105 ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u32
107}
108
109pub 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
124fn 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
133pub(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
156fn validate_host_device(path: &Path) -> Result<PathBuf> {
158 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
183pub fn discover_gpu_at(dev_root: &Path, vendor: GpuVendor) -> Result<Option<GpuDeviceSet>> {
188 discover_gpu_with(dev_root, vendor, is_char_device)
189}
190
191pub(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 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 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 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 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
304fn 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
344fn 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 set.amd = true;
357 set.intel = true;
358 }
359}
360
361pub 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 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 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#[derive(Debug, Clone, Default)]
440pub struct GpuMountResult {
441 pub bound_devices: Vec<(PathBuf, PathBuf)>,
443 pub bound_support: Vec<PathBuf>,
445 pub nvidia: bool,
447 pub amd: bool,
448 pub intel: bool,
449}
450
451pub 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 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 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 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 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 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 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 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;