From fd6927581e995354a43742aa3bdba88ab33b4e34 Mon Sep 17 00:00:00 2001 From: Benjamin Neff Date: Wed, 7 Jan 2026 11:34:27 +0100 Subject: [PATCH] Allow to have optional devices This is useful when you have hardware with different paths and different amounts of devices, where the current config isn't flexible enough. Because if some paths are missing, the whole device isn't available and the containers don't start. And if you configure it as multiple groups, it only mounts one of them, even if multiple devices are available. This now allows to specify multiple paths and mark them as optional. So the device is always available, even if some paths are missing, but it mounts all paths that are available. This doesn't handle changes while containers are running, so if paths only become available later, it doesn't restart containers using them. So this is just to handle different static hardware configurations more flexible. --- README.md | 2 + config.go | 2 + deviceplugin/path.go | 21 +++++++- deviceplugin/path_test.go | 109 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49c2b8b..e0ecac0 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Usage of generic-device-plugin: A "count" can be specified to allow a discovered device group to be scheduled multiple times. For example, to permit allocation of the FUSE device 10 times: {"name": "fuse", "groups": [{"count": 10, "paths": [{"path": "/dev/fuse"}]}]} Note: if omitted, "count" is assumed to be 1 + An "optional" field can be specified for individual paths to allow containers to start even when some devices are missing. + For example, to expose serial devices that may or may not be present: {"name": "serial", "groups": [{"paths": [{"path": "/dev/ttyS0", "optional": true}, {"path": "/dev/ttyUSB0", "optional": true}]}]} If mountPath is a directory, the device will be mounted to the directory with the name of the device. For example, to expose the serial devices to the /dev/serial directory: {"name": "serial", "groups": [{"paths": [{"path": "/dev/ttyUSB*", "mountPath": "/dev/serial/"}]}]} --domain string The domain to use when when declaring devices. (default "squat.ai") diff --git a/config.go b/config.go index 4903c06..0da619a 100644 --- a/config.go +++ b/config.go @@ -47,6 +47,8 @@ For example, to expose a CH340 serial converter: {"name": "ch340", "groups": [{" A "count" can be specified to allow a discovered device group to be scheduled multiple times. For example, to permit allocation of the FUSE device 10 times: {"name": "fuse", "groups": [{"count": 10, "paths": [{"path": "/dev/fuse"}]}]} Note: if omitted, "count" is assumed to be 1 +An "optional" field can be specified for individual paths to allow containers to start even when some devices are missing. +For example, to expose serial devices that may or may not be present: {"name": "serial", "groups": [{"paths": [{"path": "/dev/ttyS0", "optional": true}, {"path": "/dev/ttyUSB0", "optional": true}]}]} If mountPath is a directory, the device will be mounted to the directory with the name of the device. For example, to expose the serial devices to the /dev/serial directory: {"name": "serial", "groups": [{"paths": [{"path": "/dev/ttyUSB*", "mountPath": "/dev/serial/"}]}]}`) flag.String("plugin-directory", v1beta1.DevicePluginPath, "The directory in which to create plugin sockets.") diff --git a/deviceplugin/path.go b/deviceplugin/path.go index 1124435..d9f5943 100644 --- a/deviceplugin/path.go +++ b/deviceplugin/path.go @@ -18,6 +18,7 @@ import ( "crypto/sha1" "fmt" "io/fs" + "math" "path/filepath" "sort" "strconv" @@ -53,6 +54,11 @@ type Path struct { // then the group will provide 5 pairs of devices. // When unspecified, Limit defaults to 1. Limit uint `json:"limit,omitempty"` + // Optional specifies whether this device path is optional. + // When true, if the device path does not exist, it will be ignored instead of causing an error. + // This allows containers to start even when some devices are not present on the system. + // When unspecified, Optional defaults to false. + Optional bool `json:"optional,omitempty"` } // PathType represents the kinds of file-system nodes that can be scheduled. @@ -71,19 +77,26 @@ func (gp *GenericPlugin) discoverPath() ([]device, error) { for _, group := range gp.ds.Groups { paths := make([][]string, len(group.Paths)) var length int - var limitLength int + limitLength := math.MaxInt + // Track which paths have matches (used for optional paths). + pathHasMatches := make([]bool, len(group.Paths)) // Discover all the devices matching each pattern in the Paths group. for i, path := range group.Paths { matches, err := fs.Glob(gp.fs, path.Path) if err != nil { return nil, err } + // If no matches found and path is optional, skip it. + if len(matches) == 0 && path.Optional { + continue + } + pathHasMatches[i] = true sort.Strings(matches) for j := uint(0); j < path.Limit; j++ { paths[i] = append(paths[i], matches...) } // Keep track of the shortest reusable length in the group. - if i == 0 || len(paths[i]) < limitLength { + if len(paths[i]) < limitLength { limitLength = len(paths[i]) } // Keep track of the greatest natural length in the group. @@ -105,6 +118,10 @@ func (gp *GenericPlugin) discoverPath() ([]device, error) { }, } for k, path := range group.Paths { + // Skip paths that had no matches (optional and missing). + if !pathHasMatches[k] { + continue + } mountPath = path.MountPath if mountPath == "" { mountPath = paths[k][i] diff --git a/deviceplugin/path_test.go b/deviceplugin/path_test.go index c9f10fd..af41e35 100644 --- a/deviceplugin/path_test.go +++ b/deviceplugin/path_test.go @@ -200,6 +200,115 @@ func TestDiscoverPaths(t *testing.T) { }, err: nil, }, + { + name: "optional paths - some missing", + ds: &DeviceSpec{ + Name: "serial", + Groups: []*Group{ + { + Paths: []*Path{ + { + Path: "/dev/ttyS0", + Optional: true, + }, + { + Path: "/dev/ttyUSB0", + Optional: true, + }, + { + Path: "/dev/ttyUSB1", + Optional: true, + }, + }, + }, + }, + }, + fs: fstest.MapFS{ + "dev/ttyUSB0": {}, + }, + out: []device{ + { + deviceSpecs: []*v1beta1.DeviceSpec{ + { + ContainerPath: "/dev/ttyUSB0", + HostPath: "/dev/ttyUSB0", + }, + }, + }, + }, + err: nil, + }, + { + name: "optional paths - all present", + ds: &DeviceSpec{ + Name: "serial", + Groups: []*Group{ + { + Paths: []*Path{ + { + Path: "/dev/ttyS0", + Optional: true, + }, + { + Path: "/dev/ttyUSB0", + Optional: true, + }, + { + Path: "/dev/ttyUSB1", + Optional: true, + }, + }, + }, + }, + }, + fs: fstest.MapFS{ + "dev/ttyS0": {}, + "dev/ttyUSB0": {}, + "dev/ttyUSB1": {}, + }, + out: []device{ + { + deviceSpecs: []*v1beta1.DeviceSpec{ + { + ContainerPath: "/dev/ttyS0", + HostPath: "/dev/ttyS0", + }, + { + ContainerPath: "/dev/ttyUSB0", + HostPath: "/dev/ttyUSB0", + }, + { + ContainerPath: "/dev/ttyUSB1", + HostPath: "/dev/ttyUSB1", + }, + }, + }, + }, + err: nil, + }, + { + name: "optional paths - all missing", + ds: &DeviceSpec{ + Name: "serial", + Groups: []*Group{ + { + Paths: []*Path{ + { + Path: "/dev/ttyS0", + Optional: true, + }, + { + Path: "/dev/ttyUSB0", + Optional: true, + }, + }, + }, + }, + }, + fs: fstest.MapFS{}, + out: []device{}, + err: nil, + }, } { t.Run(tc.name, func(t *testing.T) { tc.ds.Default()