diff --git a/cmd/nvidia-container-runtime/README.md b/cmd/nvidia-container-runtime/README.md index af4e1040..705701ee 100644 --- a/cmd/nvidia-container-runtime/README.md +++ b/cmd/nvidia-container-runtime/README.md @@ -18,6 +18,7 @@ experimental = true When this setting is enabled, the modifications made to the OCI specification are controlled by the `nvidia-container-runtime.discover-mode` option, with the following mode supported: * `"legacy"`: This mode mirrors the behaviour of the standard mode, inserting the NVIDIA Container Runtime Hook as a `prestart` hook into the container's OCI specification. +* `"csv"`: This mode uses CSV files at `/etc/nvidia-container-runtime/host-files-for-container.d` to define the devices and mounts that are to be injected into a container when it is created. ### Notes on using the docker CLI diff --git a/cmd/nvidia-container-runtime/modifier/experimental.go b/cmd/nvidia-container-runtime/modifier/experimental.go index d5a42c05..8ce5cf12 100644 --- a/cmd/nvidia-container-runtime/modifier/experimental.go +++ b/cmd/nvidia-container-runtime/modifier/experimental.go @@ -21,6 +21,7 @@ import ( "github.com/NVIDIA/nvidia-container-toolkit/internal/config" "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover/csv" "github.com/NVIDIA/nvidia-container-toolkit/internal/edits" "github.com/NVIDIA/nvidia-container-toolkit/internal/oci" "github.com/opencontainers/runtime-spec/specs-go" @@ -33,9 +34,27 @@ type experimental struct { discoverer discover.Discover } +const ( + visibleDevicesEnvvar = "NVIDIA_VISIBLE_DEVICES" + visibleDevicesVoid = "void" + + nvidiaRequireJetpackEnvvar = "NVIDIA_REQUIRE_JETPACK" +) + // NewExperimentalModifier creates a modifier that applies the experimental // modications to an OCI spec if required by the runtime wrapper. -func NewExperimentalModifier(logger *logrus.Logger, cfg *config.Config) (oci.SpecModifier, error) { +func NewExperimentalModifier(logger *logrus.Logger, cfg *config.Config, ociSpec oci.Spec) (oci.SpecModifier, error) { + if err := ociSpec.Load(); err != nil { + return nil, fmt.Errorf("failed to load OCI spec: %v", err) + } + + // In experimental mode, we check whether a modification is required at all and return the lowlevelRuntime directly + // if no modification is required. + visibleDevices, exists := ociSpec.LookupEnv(visibleDevicesEnvvar) + if !exists || visibleDevices == "" || visibleDevices == visibleDevicesVoid { + logger.Infof("No modification required: %v=%v (exists=%v)", visibleDevicesEnvvar, visibleDevices, exists) + return nil, nil + } logger.Infof("Constructing modifier from config: %+v", cfg) root := cfg.NVIDIAContainerCLIConfig.Root @@ -48,6 +67,22 @@ func NewExperimentalModifier(logger *logrus.Logger, cfg *config.Config) (oci.Spe return nil, fmt.Errorf("failed to create legacy discoverer: %v", err) } d = legacyDiscoverer + case "csv": + csvFiles, err := csv.GetFileList(csv.DefaultMountSpecPath) + if err != nil { + return nil, fmt.Errorf("failed to get list of CSV files: %v", err) + } + + nvidiaRequireJetpack, _ := ociSpec.LookupEnv(nvidiaRequireJetpackEnvvar) + if nvidiaRequireJetpack != "csv-mounts=all" { + csvFiles = csv.BaseFilesOnly(csvFiles) + } + + csvDiscoverer, err := discover.NewFromCSVFiles(logger, csvFiles, root) + if err != nil { + return nil, fmt.Errorf("failed to create CSV discoverer: %v", err) + } + d = csvDiscoverer default: return nil, fmt.Errorf("invalid discover mode: %v", cfg.NVIDIAContainerRuntimeConfig.DiscoverMode) } diff --git a/cmd/nvidia-container-runtime/modifier/experimental_test.go b/cmd/nvidia-container-runtime/modifier/experimental_test.go index aefcff17..271a2a87 100644 --- a/cmd/nvidia-container-runtime/modifier/experimental_test.go +++ b/cmd/nvidia-container-runtime/modifier/experimental_test.go @@ -22,25 +22,54 @@ import ( "github.com/NVIDIA/nvidia-container-toolkit/internal/config" "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/NVIDIA/nvidia-container-toolkit/internal/oci" "github.com/opencontainers/runtime-spec/specs-go" testlog "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" ) -func TestConstructor(t *testing.T) { +func TestNewExperimentalModifier(t *testing.T) { logger, _ := testlog.NewNullLogger() testCases := []struct { - description string - cfg *config.Config - expectedError error + description string + cfg *config.Config + spec oci.Spec + visibleDevices string + expectedError error + expectedNil bool }{ + { + description: "spec load error returns error", + spec: &oci.SpecMock{ + LoadFunc: func() error { + return fmt.Errorf("load failed") + }, + }, + expectedError: fmt.Errorf("load failed"), + }, + { + description: "visible devices not set returns nil", + visibleDevices: "NOT_SET", + expectedNil: true, + }, + { + description: "visible devices empty returns nil", + visibleDevices: "", + expectedNil: true, + }, + { + description: "visible devices 'void' returns nil", + visibleDevices: "void", + expectedNil: true, + }, { description: "empty config raises error", cfg: &config.Config{ NVIDIAContainerRuntimeConfig: config.RuntimeConfig{}, }, - expectedError: fmt.Errorf("invalid discover mode"), + visibleDevices: "all", + expectedError: fmt.Errorf("invalid discover mode"), }, { description: "non-legacy discover mode raises error", @@ -49,7 +78,8 @@ func TestConstructor(t *testing.T) { DiscoverMode: "non-legacy", }, }, - expectedError: fmt.Errorf("invalid discover mode"), + visibleDevices: "all", + expectedError: fmt.Errorf("invalid discover mode"), }, { description: "legacy discover mode returns modifier", @@ -58,17 +88,45 @@ func TestConstructor(t *testing.T) { DiscoverMode: "legacy", }, }, + visibleDevices: "all", + }, + { + description: "csv discover mode returns modifier", + cfg: &config.Config{ + NVIDIAContainerRuntimeConfig: config.RuntimeConfig{ + DiscoverMode: "csv", + }, + }, + visibleDevices: "all", }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { - _, err := NewExperimentalModifier(logger, tc.cfg) + spec := tc.spec + if spec == nil { + spec = &oci.SpecMock{ + LookupEnvFunc: func(s string) (string, bool) { + if tc.visibleDevices != "NOT_SET" && s == visibleDevicesEnvvar { + return tc.visibleDevices, true + } + return "", false + }, + } + } + + m, err := NewExperimentalModifier(logger, tc.cfg, spec) if tc.expectedError != nil { require.Error(t, err) } else { require.NoError(t, err) } + + if tc.expectedNil || tc.expectedError != nil { + require.Nil(t, m) + } else { + require.NotNil(t, m) + } }) } } diff --git a/cmd/nvidia-container-runtime/runtime_factory.go b/cmd/nvidia-container-runtime/runtime_factory.go index 8eb23302..20025e45 100644 --- a/cmd/nvidia-container-runtime/runtime_factory.go +++ b/cmd/nvidia-container-runtime/runtime_factory.go @@ -44,10 +44,14 @@ func newNVIDIAContainerRuntime(logger *logrus.Logger, cfg *config.Config, argv [ return nil, fmt.Errorf("error constructing low-level runtime: %v", err) } - specModifier, err := newSpecModifier(logger, cfg) + specModifier, err := newSpecModifier(logger, cfg, ociSpec) if err != nil { return nil, fmt.Errorf("failed to construct OCI spec modifier: %v", err) } + if specModifier == nil { + logger.Infof("Using low-level runtime with no modification") + return lowLevelRuntime, nil + } // Create the wrapping runtime with the specified modifier r := runtime.NewModifyingRuntimeWrapper( @@ -61,10 +65,10 @@ func newNVIDIAContainerRuntime(logger *logrus.Logger, cfg *config.Config, argv [ } // newSpecModifier is a factory method that creates constructs an OCI spec modifer based on the provided config. -func newSpecModifier(logger *logrus.Logger, cfg *config.Config) (oci.SpecModifier, error) { +func newSpecModifier(logger *logrus.Logger, cfg *config.Config, ociSpec oci.Spec) (oci.SpecModifier, error) { if !cfg.NVIDIAContainerRuntimeConfig.Experimental { return modifier.NewStableRuntimeModifier(logger), nil } - return modifier.NewExperimentalModifier(logger, cfg) + return modifier.NewExperimentalModifier(logger, cfg, ociSpec) } diff --git a/cmd/nvidia-container-runtime/runtime_factory_test.go b/cmd/nvidia-container-runtime/runtime_factory_test.go index 145970d7..a1bbef26 100644 --- a/cmd/nvidia-container-runtime/runtime_factory_test.go +++ b/cmd/nvidia-container-runtime/runtime_factory_test.go @@ -17,9 +17,13 @@ package main import ( + "encoding/json" + "os" + "path/filepath" "testing" "github.com/NVIDIA/nvidia-container-toolkit/internal/config" + "github.com/opencontainers/runtime-spec/specs-go" testlog "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" ) @@ -30,7 +34,7 @@ func TestFactoryMethod(t *testing.T) { testCases := []struct { description string cfg *config.Config - argv []string + spec *specs.Spec expectedError bool }{ { @@ -39,11 +43,35 @@ func TestFactoryMethod(t *testing.T) { NVIDIAContainerRuntimeConfig: config.RuntimeConfig{}, }, }, + { + description: "experimental flag supported", + cfg: &config.Config{ + NVIDIAContainerRuntimeConfig: config.RuntimeConfig{ + Experimental: true, + DiscoverMode: "legacy", + }, + }, + spec: &specs.Spec{ + Process: &specs.Process{ + Env: []string{ + "NVIDIA_VISIBLE_DEVICES=all", + }, + }, + }, + }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { - _, err := newNVIDIAContainerRuntime(logger, tc.cfg, tc.argv) + bundleDir := t.TempDir() + + specFile, err := os.Create(filepath.Join(bundleDir, "config.json")) + require.NoError(t, err) + require.NoError(t, json.NewEncoder(specFile).Encode(tc.spec)) + + argv := []string{"--bundle", bundleDir} + + _, err = newNVIDIAContainerRuntime(logger, tc.cfg, argv) if tc.expectedError { require.Error(t, err) } else { diff --git a/internal/discover/csv.go b/internal/discover/csv.go new file mode 100644 index 00000000..59a1eed4 --- /dev/null +++ b/internal/discover/csv.go @@ -0,0 +1,148 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package discover + +import ( + "fmt" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover/csv" + "github.com/NVIDIA/nvidia-container-toolkit/internal/lookup" + "github.com/sirupsen/logrus" +) + +type csvDiscoverer struct { + mounts + filename string + mountType csv.MountSpecType +} + +var _ Discover = (*csvDiscoverer)(nil) + +// NewFromCSVFiles creates a discoverer for the specified CSV files. A logger is also supplied. +// The constructed discoverer is comprised of a list, with each element in the list being associated with a +// single CSV files. +func NewFromCSVFiles(logger *logrus.Logger, files []string, root string) (Discover, error) { + if len(files) == 0 { + logger.Warnf("No CSV files specified") + return None{}, nil + } + + symlinkLocator := lookup.NewSymlinkLocator(logger, root) + locators := map[csv.MountSpecType]lookup.Locator{ + csv.MountSpecDev: lookup.NewCharDeviceLocator(logger, root), + csv.MountSpecDir: lookup.NewDirectoryLocator(logger, root), + // Libraries and symlinks are handled in the same way + csv.MountSpecLib: symlinkLocator, + csv.MountSpecSym: symlinkLocator, + } + + var discoverers []Discover + for _, filename := range files { + d, err := NewFromCSVFile(logger, locators, filename) + if err != nil { + logger.Warnf("Skipping CSV file %v: %v", filename, err) + continue + } + discoverers = append(discoverers, d) + } + + return &list{discoverers: discoverers}, nil +} + +// NewFromCSVFile creates a discoverer for the specified CSV file. A logger is also supplied. +// The constructed discoverer is comprised of a list, with each element in the list being associated with a particular +// MountSpecType. +func NewFromCSVFile(logger *logrus.Logger, locators map[csv.MountSpecType]lookup.Locator, filename string) (Discover, error) { + // Create a discoverer for each file-kind combination + targets, err := csv.ParseFile(logger, filename) + if err != nil { + return nil, fmt.Errorf("failed to parse CSV file: %v", err) + } + if len(targets) == 0 { + return nil, fmt.Errorf("CSV file is empty") + } + + csvDiscoverers, err := newFromMountSpecs(logger, locators, targets) + if err != nil { + return nil, err + } + var discoverers []Discover + for _, d := range csvDiscoverers { + d.filename = filename + discoverers = append(discoverers, d) + } + + return &list{discoverers: discoverers}, nil +} + +// newFromMountSpecs creates a discoverer for the CSV file. A logger is also supplied. +// A list of csvDiscoverers is returned, with each being associated with a single MountSpecType. +func newFromMountSpecs(logger *logrus.Logger, locators map[csv.MountSpecType]lookup.Locator, targets []*csv.MountSpec) ([]*csvDiscoverer, error) { + var discoverers []*csvDiscoverer + candidatesByType := make(map[csv.MountSpecType][]string) + for _, t := range targets { + candidatesByType[t.Type] = append(candidatesByType[t.Type], t.Path) + } + + for t, candidates := range candidatesByType { + locator, exists := locators[t] + if !exists { + return nil, fmt.Errorf("no locator defined for '%v'", t) + } + d := csvDiscoverer{ + mounts: mounts{ + logger: logger, + lookup: locator, + required: candidates, + }, + mountType: t, + } + discoverers = append(discoverers, &d) + } + + return discoverers, nil +} + +// Mounts returns the discovered mounts for the csvDiscoverer. +// Note that if the discoverer is for the device MountSpecType, the list of mounts is empty. +func (d csvDiscoverer) Mounts() ([]Mount, error) { + if d.mountType == csv.MountSpecDev { + return d.None.Mounts() + } + + return d.mounts.Mounts() +} + +// Devices returns the discovered devices for the csvDiscoverer. +// Note that if the discoverer is not for the device MountSpecType, the list of devices is empty. +func (d csvDiscoverer) Devices() ([]Device, error) { + if d.mountType != csv.MountSpecDev { + return d.None.Devices() + } + + mounts, err := d.mounts.Mounts() + if err != nil { + return nil, err + } + var devices []Device + for _, mount := range mounts { + device := Device(mount) + devices = append(devices, device) + } + + return devices, nil +} diff --git a/internal/discover/csv/csv.go b/internal/discover/csv/csv.go new file mode 100644 index 00000000..f3625571 --- /dev/null +++ b/internal/discover/csv/csv.go @@ -0,0 +1,109 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package csv + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/sirupsen/logrus" +) + +const ( + // DefaultMountSpecPath is default location of CSV files that define the modifications required to the OCI spec + DefaultMountSpecPath = "/etc/nvidia-container-runtime/host-files-for-container.d" +) + +// GetFileList returns the (non-recursive) list of CSV files in the specified +// folder +func GetFileList(root string) ([]string, error) { + contents, err := os.ReadDir(root) + if err != nil && errors.Is(err, os.ErrNotExist) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed to read the contents of %v: %v", root, err) + } + + var csvFilePaths []string + for _, c := range contents { + if c.IsDir() { + continue + } + if c.Name() == ".csv" { + continue + } + ext := strings.ToLower(filepath.Ext(c.Name())) + if ext != ".csv" { + continue + } + + csvFilePaths = append(csvFilePaths, filepath.Join(root, c.Name())) + } + + return csvFilePaths, nil +} + +// BaseFilesOnly filters out non-base CSV files from the list of CSV files. +func BaseFilesOnly(filenames []string) []string { + filter := map[string]bool{ + "l4t.csv": true, + "drivers.csv": true, + "devices.csv": true, + } + + var selected []string + for _, file := range filenames { + base := filepath.Base(file) + if filter[base] { + selected = append(selected, file) + } + } + + return selected +} + +// ParseFile parses the specified file and returns a list of required jetson mounts +func ParseFile(logger *logrus.Logger, filename string) ([]*MountSpec, error) { + csvFile, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open %v for reading: %v", filename, err) + } + return parseCSVFromReader(logger, csvFile), nil +} + +// parseCSVFromReader parses the specified file and returns a list of required jetson mounts +func parseCSVFromReader(logger *logrus.Logger, reader io.Reader) []*MountSpec { + var targets []*MountSpec + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + target, err := NewMountSpecFromLine(line) + if err != nil { + logger.Debugf("Skipping invalid mount spec '%v': %v", line, err) + continue + } + targets = append(targets, target) + } + + return targets +} diff --git a/internal/discover/csv/csv_test.go b/internal/discover/csv/csv_test.go new file mode 100644 index 00000000..919f4add --- /dev/null +++ b/internal/discover/csv/csv_test.go @@ -0,0 +1,83 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package csv + +import ( + "path/filepath" + "testing" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/test" + "github.com/stretchr/testify/require" +) + +func TestGetFileList(t *testing.T) { + moduleRoot, _ := test.GetModuleRoot() + + testCases := []struct { + description string + root string + files []string + expectedError error + }{ + { + description: "returns list of CSV files", + root: "test/input/csv_samples/", + files: []string{ + "jetson.csv", + "simple_wrong.csv", + "simple.csv", + "spaced.csv", + }, + }, + { + description: "handles empty folder", + root: "test/input/csv_samples/empty", + }, + { + description: "handles non-existent folder", + root: "test/input/csv_samples/NONEXISTENT", + }, + { + description: "handles non-existent folder root", + root: "/NONEXISTENT/test/input/csv_samples/", + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + root := filepath.Join(moduleRoot, tc.root) + files, err := GetFileList(root) + + if tc.expectedError != nil { + require.Error(t, err) + require.Empty(t, files) + return + } + + require.NoError(t, err) + + var foundFiles []string + for _, f := range files { + require.Equal(t, root, filepath.Dir(f)) + require.Equal(t, ".csv", filepath.Ext(f)) + foundFiles = append(foundFiles, filepath.Base(f)) + } + + require.ElementsMatch(t, tc.files, foundFiles) + }) + } +} diff --git a/internal/discover/csv/mount_spec.go b/internal/discover/csv/mount_spec.go new file mode 100644 index 00000000..3ab5f9bf --- /dev/null +++ b/internal/discover/csv/mount_spec.go @@ -0,0 +1,74 @@ +/** +# Copyright (c) 2021-2022, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package csv + +import ( + "fmt" + "strings" +) + +// MountSpecType defines the mount types allowed in a CSV file +type MountSpecType string + +const ( + // MountSpecDev is used for character devices + MountSpecDev = MountSpecType("dev") + // MountSpecDir is used for directories + MountSpecDir = MountSpecType("dir") + // MountSpecLib is used for libraries or regular files + MountSpecLib = MountSpecType("lib") + // MountSpecSym is used for symlinks. + MountSpecSym = MountSpecType("sym") +) + +// MountSpec represents a Jetson mount consisting of a type and a path. +type MountSpec struct { + Type MountSpecType + Path string +} + +// NewMountSpecFromLine parses the specified line and returns the MountSpec or an error if the line is malformed +func NewMountSpecFromLine(line string) (*MountSpec, error) { + parts := strings.SplitN(strings.TrimSpace(line), ",", 2) + if len(parts) < 2 { + return nil, fmt.Errorf("failed to parse line: %v", line) + } + mountType := strings.TrimSpace(parts[0]) + path := strings.TrimSpace(parts[1]) + + return NewMountSpec(mountType, path) +} + +// NewMountSpec creates a MountSpec with the specified type and path. An error is returned if the type is invalid. +func NewMountSpec(mountType string, path string) (*MountSpec, error) { + mt := MountSpecType(mountType) + switch mt { + case MountSpecDev, MountSpecLib, MountSpecSym, MountSpecDir: + default: + return nil, fmt.Errorf("unexpected mount type: %v", mt) + } + if path == "" { + return nil, fmt.Errorf("invalid path: %v", path) + } + + mount := MountSpec{ + Type: mt, + Path: path, + } + + return &mount, nil +} diff --git a/internal/discover/csv/mount_spec_test.go b/internal/discover/csv/mount_spec_test.go new file mode 100644 index 00000000..110ca8c1 --- /dev/null +++ b/internal/discover/csv/mount_spec_test.go @@ -0,0 +1,82 @@ +/** +# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package csv + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewMountSpecFromLine(t *testing.T) { + parseError := fmt.Errorf("failed to parse line") + unexpectedError := fmt.Errorf("unexpected mount type") + + testCases := []struct { + line string + expectedError error + expectedValue MountSpec + }{ + { + line: "", + expectedError: parseError, + }, + { + line: "\t", + expectedError: parseError, + }, + { + line: ",", + expectedError: parseError, + }, + { + line: "dev,", + expectedError: parseError, + }, + { + line: "dev ,/a/path", + expectedValue: MountSpec{ + Path: "/a/path", + Type: "dev", + }, + }, + { + line: "dev ,/a/path,with,commas", + expectedValue: MountSpec{ + Path: "/a/path,with,commas", + Type: "dev", + }, + }, + { + line: "not-dev ,/a/path", + expectedError: unexpectedError, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + target, err := NewMountSpecFromLine(tc.line) + if tc.expectedError != nil { + require.Error(t, err) + return + } + require.NoError(t, err) + require.EqualValues(t, &tc.expectedValue, target) + }) + } +} diff --git a/internal/discover/csv_test.go b/internal/discover/csv_test.go new file mode 100644 index 00000000..f11d7af0 --- /dev/null +++ b/internal/discover/csv_test.go @@ -0,0 +1,186 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package discover + +import ( + "fmt" + "testing" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover/csv" + "github.com/NVIDIA/nvidia-container-toolkit/internal/lookup" + testlog "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/require" +) + +func TestCSVDiscoverer(t *testing.T) { + logger, logHook := testlog.NewNullLogger() + + testCases := []struct { + description string + input csvDiscoverer + expectedMounts []Mount + expectedMountsError error + expectedDevicesError error + expectedDevices []Device + }{ + { + description: "dev mounts are empty", + input: csvDiscoverer{ + mounts: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(string) ([]string, error) { + return []string{"located"}, nil + }, + }, + required: []string{"required"}, + }, + mountType: "dev", + }, + expectedDevices: []Device{{Path: "located"}}, + }, + { + description: "dev devices returns error for nil lookup", + input: csvDiscoverer{ + mountType: "dev", + }, + expectedDevicesError: fmt.Errorf("no lookup defined"), + }, + { + description: "lib devices are empty", + input: csvDiscoverer{ + mounts: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(string) ([]string, error) { + return []string{"located"}, nil + }, + }, + required: []string{"required"}, + }, + mountType: "lib", + }, + expectedMounts: []Mount{{Path: "located"}}, + }, + { + description: "lib mounts returns error for nil lookup", + input: csvDiscoverer{ + mountType: "lib", + }, + expectedMountsError: fmt.Errorf("no lookup defined"), + }, + } + + for _, tc := range testCases { + logHook.Reset() + + t.Run(tc.description, func(t *testing.T) { + tc.input.logger = logger + + mounts, err := tc.input.Mounts() + if tc.expectedMountsError != nil { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.ElementsMatch(t, tc.expectedMounts, mounts) + + devices, err := tc.input.Devices() + if tc.expectedDevicesError != nil { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.ElementsMatch(t, tc.expectedDevices, devices) + }) + } +} + +func TestNewFromMountSpec(t *testing.T) { + logger, _ := testlog.NewNullLogger() + + locators := map[csv.MountSpecType]lookup.Locator{ + "dev": &lookup.LocatorMock{}, + "lib": &lookup.LocatorMock{}, + } + + testCases := []struct { + description string + targets []*csv.MountSpec + expectedError error + expectedCSVDiscoverers []*csvDiscoverer + }{ + { + description: "empty targets returns empyt list", + }, + { + description: "unexpected locator returns error", + targets: []*csv.MountSpec{ + { + Type: "foo", + Path: "bar", + }, + }, + expectedError: fmt.Errorf("no locator defined for foo"), + }, + { + description: "creates discoverers based on type", + targets: []*csv.MountSpec{ + { + Type: "dev", + Path: "dev0", + }, + { + Type: "lib", + Path: "lib0", + }, + { + Type: "dev", + Path: "dev1", + }, + }, + expectedCSVDiscoverers: []*csvDiscoverer{ + { + mountType: "dev", + mounts: mounts{ + logger: logger, + lookup: locators["dev"], + required: []string{"dev0", "dev1"}, + }, + }, + { + mountType: "lib", + mounts: mounts{ + logger: logger, + lookup: locators["lib"], + required: []string{"lib0"}, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + discoverers, err := newFromMountSpecs(logger, locators, tc.targets) + if tc.expectedError != nil { + require.Error(t, err) + return + } + require.NoError(t, err) + require.ElementsMatch(t, tc.expectedCSVDiscoverers, discoverers) + }) + } +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go index 4858b282..77a8fdd5 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -16,6 +16,16 @@ package discover +// Device represents a discovered character device. +type Device struct { + Path string +} + +// Mount represents a discovered mount. +type Mount struct { + Path string +} + // Hook represents a discovered hook. type Hook struct { Lifecycle string @@ -26,5 +36,7 @@ type Hook struct { //go:generate moq -stub -out discover_mock.go . Discover // Discover defines an interface for discovering the devices, mounts, and hooks available on a system type Discover interface { + Devices() ([]Device, error) + Mounts() ([]Mount, error) Hooks() ([]Hook, error) } diff --git a/internal/discover/discover_mock.go b/internal/discover/discover_mock.go index 0a0f211f..e3f57579 100644 --- a/internal/discover/discover_mock.go +++ b/internal/discover/discover_mock.go @@ -17,9 +17,15 @@ var _ Discover = &DiscoverMock{} // // // make and configure a mocked Discover // mockedDiscover := &DiscoverMock{ +// DevicesFunc: func() ([]Device, error) { +// panic("mock out the Devices method") +// }, // HooksFunc: func() ([]Hook, error) { // panic("mock out the Hooks method") // }, +// MountsFunc: func() ([]Mount, error) { +// panic("mock out the Mounts method") +// }, // } // // // use mockedDiscover in code that requires Discover @@ -27,16 +33,60 @@ var _ Discover = &DiscoverMock{} // // } type DiscoverMock struct { + // DevicesFunc mocks the Devices method. + DevicesFunc func() ([]Device, error) + // HooksFunc mocks the Hooks method. HooksFunc func() ([]Hook, error) + // MountsFunc mocks the Mounts method. + MountsFunc func() ([]Mount, error) + // calls tracks calls to the methods. calls struct { + // Devices holds details about calls to the Devices method. + Devices []struct { + } // Hooks holds details about calls to the Hooks method. Hooks []struct { } + // Mounts holds details about calls to the Mounts method. + Mounts []struct { + } } - lockHooks sync.RWMutex + lockDevices sync.RWMutex + lockHooks sync.RWMutex + lockMounts sync.RWMutex +} + +// Devices calls DevicesFunc. +func (mock *DiscoverMock) Devices() ([]Device, error) { + callInfo := struct { + }{} + mock.lockDevices.Lock() + mock.calls.Devices = append(mock.calls.Devices, callInfo) + mock.lockDevices.Unlock() + if mock.DevicesFunc == nil { + var ( + devicesOut []Device + errOut error + ) + return devicesOut, errOut + } + return mock.DevicesFunc() +} + +// DevicesCalls gets all the calls that were made to Devices. +// Check the length with: +// len(mockedDiscover.DevicesCalls()) +func (mock *DiscoverMock) DevicesCalls() []struct { +} { + var calls []struct { + } + mock.lockDevices.RLock() + calls = mock.calls.Devices + mock.lockDevices.RUnlock() + return calls } // Hooks calls HooksFunc. @@ -68,3 +118,33 @@ func (mock *DiscoverMock) HooksCalls() []struct { mock.lockHooks.RUnlock() return calls } + +// Mounts calls MountsFunc. +func (mock *DiscoverMock) Mounts() ([]Mount, error) { + callInfo := struct { + }{} + mock.lockMounts.Lock() + mock.calls.Mounts = append(mock.calls.Mounts, callInfo) + mock.lockMounts.Unlock() + if mock.MountsFunc == nil { + var ( + mountsOut []Mount + errOut error + ) + return mountsOut, errOut + } + return mock.MountsFunc() +} + +// MountsCalls gets all the calls that were made to Mounts. +// Check the length with: +// len(mockedDiscover.MountsCalls()) +func (mock *DiscoverMock) MountsCalls() []struct { +} { + var calls []struct { + } + mock.lockMounts.RLock() + calls = mock.calls.Mounts + mock.lockMounts.RUnlock() + return calls +} diff --git a/internal/discover/legacy.go b/internal/discover/legacy.go index 53f6ffc1..0d0d8e38 100644 --- a/internal/discover/legacy.go +++ b/internal/discover/legacy.go @@ -23,6 +23,7 @@ import ( ) type legacy struct { + None logger *logrus.Logger lookup lookup.Locator } @@ -38,7 +39,7 @@ var _ Discover = (*legacy)(nil) func NewLegacyDiscoverer(logger *logrus.Logger, root string) (Discover, error) { d := legacy{ logger: logger, - lookup: lookup.NewExecutaleLocator(logger, root), + lookup: lookup.NewExecutableLocator(logger, root), } return &d, nil diff --git a/internal/discover/list.go b/internal/discover/list.go new file mode 100644 index 00000000..f19c8c15 --- /dev/null +++ b/internal/discover/list.go @@ -0,0 +1,73 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package discover + +import "fmt" + +// list is a discoverer that contains a list of Discoverers. The output of the +// Mounts functions is the concatenation of the output for each of the +// elements in the list. +type list struct { + discoverers []Discover +} + +var _ Discover = (*list)(nil) + +// Devices returns all devices from the included discoverers +func (d list) Devices() ([]Device, error) { + var allDevices []Device + + for i, di := range d.discoverers { + devices, err := di.Devices() + if err != nil { + return nil, fmt.Errorf("error discovering devices for discoverer %v: %v", i, err) + } + allDevices = append(allDevices, devices...) + } + + return allDevices, nil +} + +// Mounts returns all mounts from the included discoverers +func (d list) Mounts() ([]Mount, error) { + var allMounts []Mount + + for i, di := range d.discoverers { + mounts, err := di.Mounts() + if err != nil { + return nil, fmt.Errorf("error discovering mounts for discoverer %v: %v", i, err) + } + allMounts = append(allMounts, mounts...) + } + + return allMounts, nil +} + +// Hooks returns all Hooks from the included discoverers +func (d list) Hooks() ([]Hook, error) { + var allHooks []Hook + + for i, di := range d.discoverers { + hooks, err := di.Hooks() + if err != nil { + return nil, fmt.Errorf("error discovering hooks for discoverer %v: %v", i, err) + } + allHooks = append(allHooks, hooks...) + } + + return allHooks, nil +} diff --git a/internal/discover/mounts.go b/internal/discover/mounts.go new file mode 100644 index 00000000..f294e522 --- /dev/null +++ b/internal/discover/mounts.go @@ -0,0 +1,72 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package discover + +import ( + "fmt" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/lookup" + "github.com/sirupsen/logrus" +) + +// mounts is a generic discoverer for Mounts. It is customized by specifying the +// required entities as a list and a Locator that is used to find the target mounts +// based on the entry in the list. +type mounts struct { + None + logger *logrus.Logger + lookup lookup.Locator + required []string +} + +var _ Discover = (*mounts)(nil) + +func (d mounts) Mounts() ([]Mount, error) { + if d.lookup == nil { + return nil, fmt.Errorf("no lookup defined") + } + + paths := make(map[string]bool) + + for _, candidate := range d.required { + d.logger.Debugf("Locating %v", candidate) + located, err := d.lookup.Locate(candidate) + if err != nil { + d.logger.Warnf("Could not locate %v: %v", candidate, err) + continue + } + if len(located) == 0 { + d.logger.Warnf("Missing %v", candidate) + continue + } + d.logger.Debugf("Located %v as %v", candidate, located) + for _, p := range located { + paths[p] = true + } + } + + var mounts []Mount + for path := range paths { + d.logger.Infof("Selecting %v", path) + mount := Mount{ + Path: path, + } + mounts = append(mounts, mount) + } + + return mounts, nil +} diff --git a/internal/discover/mounts_test.go b/internal/discover/mounts_test.go new file mode 100644 index 00000000..35e68e76 --- /dev/null +++ b/internal/discover/mounts_test.go @@ -0,0 +1,164 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package discover + +import ( + "fmt" + "testing" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/lookup" + "github.com/stretchr/testify/require" + + testlog "github.com/sirupsen/logrus/hooks/test" +) + +func TestMountsReturnsEmptyDevices(t *testing.T) { + d := mounts{} + devices, err := d.Devices() + + require.NoError(t, err) + require.Empty(t, devices) +} + +func TestMounts(t *testing.T) { + logger, logHook := testlog.NewNullLogger() + + testCases := []struct { + description string + expectedError error + expectedMounts []Mount + input mounts + }{ + { + description: "nill lookup returns error", + expectedError: fmt.Errorf("no lookup defined"), + }, + { + description: "empty required returns no mounts", + expectedError: nil, + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(string) ([]string, error) { + return []string{"located"}, nil + }, + }, + }, + }, + { + description: "required returns located", + expectedError: nil, + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(string) ([]string, error) { + return []string{"located"}, nil + }, + }, + required: []string{"required"}, + }, + expectedMounts: []Mount{{Path: "located"}}, + }, + { + description: "mounts removes located duplicates", + expectedError: nil, + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(string) ([]string, error) { + return []string{"located"}, nil + }, + }, + required: []string{"required0", "required1"}, + }, + expectedMounts: []Mount{{Path: "located"}}, + }, + { + description: "mounts skips located errors", + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(s string) ([]string, error) { + if s == "error" { + return nil, fmt.Errorf(s) + } + return []string{s}, nil + }, + }, + required: []string{"required0", "error", "required1"}, + }, + expectedMounts: []Mount{{Path: "required0"}, {Path: "required1"}}, + }, + { + description: "mounts skips unlocated", + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(s string) ([]string, error) { + if s == "empty" { + return nil, nil + } + return []string{s}, nil + }, + }, + required: []string{"required0", "empty", "required1"}, + }, + expectedMounts: []Mount{{Path: "required0"}, {Path: "required1"}}, + }, + { + description: "mounts skips unlocated", + input: mounts{ + lookup: &lookup.LocatorMock{ + LocateFunc: func(s string) ([]string, error) { + if s == "multiple" { + return []string{"multiple0", "multiple1"}, nil + } + return []string{s}, nil + }, + }, + required: []string{"required0", "multiple", "required1"}, + }, + expectedMounts: []Mount{ + {Path: "required0"}, + {Path: "multiple0"}, + {Path: "multiple1"}, + {Path: "required1"}, + }, + }, + } + + for _, tc := range testCases { + logHook.Reset() + t.Run(tc.description, func(t *testing.T) { + tc.input.logger = logger + mounts, err := tc.input.Mounts() + + if tc.expectedError != nil { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.ElementsMatch(t, tc.expectedMounts, mounts) + + // We check that the mock is called for each element of required + if tc.input.lookup != nil { + mock := tc.input.lookup.(*lookup.LocatorMock) + require.Len(t, mock.LocateCalls(), len(tc.input.required)) + var args []string + for _, c := range mock.LocateCalls() { + args = append(args, c.S) + } + require.EqualValues(t, args, tc.input.required) + } + }) + } +} diff --git a/internal/discover/none.go b/internal/discover/none.go new file mode 100644 index 00000000..989a2e16 --- /dev/null +++ b/internal/discover/none.go @@ -0,0 +1,38 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package discover + +// None is a null discoverer that returns an empty list of devices and +// mounts. +type None struct{} + +var _ Discover = (*None)(nil) + +// Devices returns an empty list of devices +func (e None) Devices() ([]Device, error) { + return []Device{}, nil +} + +// Mounts returns an empty list of mounts +func (e None) Mounts() ([]Mount, error) { + return []Mount{}, nil +} + +// Hooks returns and empty list of hooks +func (e None) Hooks() ([]Hook, error) { + return []Hook{}, nil +} diff --git a/internal/discover/none_test.go b/internal/discover/none_test.go new file mode 100644 index 00000000..d69bbd53 --- /dev/null +++ b/internal/discover/none_test.go @@ -0,0 +1,31 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package discover + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNone(t *testing.T) { + d := None{} + + mounts, err := d.Mounts() + require.NoError(t, err) + require.Empty(t, mounts) +} diff --git a/internal/edits/device.go b/internal/edits/device.go new file mode 100644 index 00000000..5e11f41d --- /dev/null +++ b/internal/edits/device.go @@ -0,0 +1,45 @@ +/** +# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package edits + +import ( + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/container-orchestrated-devices/container-device-interface/pkg/cdi" + "github.com/container-orchestrated-devices/container-device-interface/specs-go" +) + +type device discover.Device + +// toEdits converts a discovered device to CDI Container Edits. +func (d device) toEdits() *cdi.ContainerEdits { + e := cdi.ContainerEdits{ + ContainerEdits: &specs.ContainerEdits{ + DeviceNodes: []*specs.DeviceNode{d.toSpec()}, + }, + } + return &e +} + +// toSpec converts a discovered Device to a CDI Spec Device. Note +// that missing info is filled in when edits are applied by querying the Device node. +func (d device) toSpec() *specs.DeviceNode { + s := specs.DeviceNode{ + Path: d.Path, + } + + return &s +} diff --git a/internal/edits/edits.go b/internal/edits/edits.go index 36fecd81..4f4ee150 100644 --- a/internal/edits/edits.go +++ b/internal/edits/edits.go @@ -34,12 +34,30 @@ type edits struct { // NewSpecEdits creates a SpecModifier that defines the required OCI spec edits (as CDI ContainerEdits) from the specified // discoverer. func NewSpecEdits(logger *logrus.Logger, d discover.Discover) (oci.SpecModifier, error) { + devices, err := d.Devices() + if err != nil { + return nil, fmt.Errorf("failed to discover devices: %v", err) + } + + mounts, err := d.Mounts() + if err != nil { + return nil, fmt.Errorf("failed to discover mounts: %v", err) + } + hooks, err := d.Hooks() if err != nil { return nil, fmt.Errorf("failed to discover hooks: %v", err) } c := cdi.ContainerEdits{} + for _, d := range devices { + c.Append(device(d).toEdits()) + } + + for _, m := range mounts { + c.Append(mount(m).toEdits()) + } + for _, h := range hooks { c.Append(hook(h).toEdits()) } @@ -58,9 +76,18 @@ func (e *edits) Modify(spec *ociSpecs.Spec) error { return nil } + e.logger.Info("Mounts:") + for _, mount := range e.Mounts { + e.logger.Infof("Mounting %v at %v", mount.HostPath, mount.ContainerPath) + } + e.logger.Infof("Devices:") + for _, device := range e.DeviceNodes { + e.logger.Infof("Injecting %v", device.Path) + } e.logger.Infof("Hooks:") for _, hook := range e.Hooks { e.logger.Infof("Injecting %v", hook.Args) } + return e.Apply(spec) } diff --git a/internal/edits/hook.go b/internal/edits/hook.go index 990d8565..a0e56a85 100644 --- a/internal/edits/hook.go +++ b/internal/edits/hook.go @@ -42,5 +42,6 @@ func (d hook) toSpec() *specs.Hook { Path: d.Path, Args: d.Args, } + return &s } diff --git a/internal/edits/mount.go b/internal/edits/mount.go new file mode 100644 index 00000000..0697321e --- /dev/null +++ b/internal/edits/mount.go @@ -0,0 +1,53 @@ +/** +# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package edits + +import ( + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/container-orchestrated-devices/container-device-interface/pkg/cdi" + "github.com/container-orchestrated-devices/container-device-interface/specs-go" +) + +type mount discover.Mount + +// toEdits converts a discovered mount to CDI Container Edits. +func (d mount) toEdits() *cdi.ContainerEdits { + e := cdi.ContainerEdits{ + ContainerEdits: &specs.ContainerEdits{ + Mounts: []*specs.Mount{d.toSpec()}, + }, + } + return &e +} + +// toSpec converts a discovered Mount to a CDI Spec Mount. Note +// that missing info is filled in when edits are applied by querying the Mount node. +func (d mount) toSpec() *specs.Mount { + s := specs.Mount{ + HostPath: d.Path, + // TODO: We need to allow the container path to be customised + ContainerPath: d.Path, + Options: []string{ + "ro", + "nosuid", + "nodev", + "bind", + }, + } + + return &s +} diff --git a/internal/lookup/device.go b/internal/lookup/device.go new file mode 100644 index 00000000..1cfc7ea2 --- /dev/null +++ b/internal/lookup/device.go @@ -0,0 +1,53 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package lookup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" +) + +const ( + devRoot = "/dev" +) + +// NewCharDeviceLocator creates a Locator that can be used to find char devices at the specified root. A logger is +// also specified. +func NewCharDeviceLocator(logger *logrus.Logger, root string) Locator { + l := file{ + logger: logger, + prefixes: []string{root, filepath.Join(root, devRoot)}, + filter: assertCharDevice, + } + + return &l +} + +// assertCharDevice checks whether the specified path is a char device and returns an error if this is not the case. +func assertCharDevice(filename string) error { + info, err := os.Stat(filename) + if err != nil { + return fmt.Errorf("error getting info: %v", err) + } + if info.Mode()|os.ModeCharDevice == 0 { + return fmt.Errorf("%v is not a char device", filename) + } + return nil +} diff --git a/internal/lookup/dir.go b/internal/lookup/dir.go new file mode 100644 index 00000000..695025a5 --- /dev/null +++ b/internal/lookup/dir.go @@ -0,0 +1,50 @@ +/* +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + +package lookup + +import ( + "fmt" + "os" + + log "github.com/sirupsen/logrus" +) + +// NewDirectoryLocator creates a Locator that can be used to find directories at the specified root. A logger +// is also specified. +func NewDirectoryLocator(logger *log.Logger, root string) Locator { + l := file{ + logger: logger, + prefixes: []string{root}, + filter: assertDirectory, + } + + return &l +} + +// assertDirectory checks wither the specified path is a directory. +func assertDirectory(filename string) error { + info, err := os.Stat(filename) + if err != nil { + return fmt.Errorf("error getting info for %v: %v", filename, err) + } + + if !info.IsDir() { + return fmt.Errorf("specified path '%v' is not a directory", filename) + } + + return nil +} diff --git a/internal/lookup/executable.go b/internal/lookup/executable.go index be7577ff..3109c440 100644 --- a/internal/lookup/executable.go +++ b/internal/lookup/executable.go @@ -35,8 +35,8 @@ type executable struct { file } -// NewExecutaleLocator creates a locator to fine executable files in the path. A logger can also be specified. -func NewExecutaleLocator(logger *log.Logger, root string) Locator { +// NewExecutableLocator creates a locator to fine executable files in the path. A logger can also be specified. +func NewExecutableLocator(logger *log.Logger, root string) Locator { pathEnv := os.Getenv(envPath) paths := filepath.SplitList(pathEnv) diff --git a/internal/lookup/symlinks.go b/internal/lookup/symlinks.go new file mode 100644 index 00000000..19684e49 --- /dev/null +++ b/internal/lookup/symlinks.go @@ -0,0 +1,123 @@ +/** +# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package lookup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" +) + +type symlinkChain struct { + file +} + +type symlink struct { + file +} + +// NewSymlinkChainLocator creats a locator that can be used for locating files through symlinks. +// A logger can also be specified. +func NewSymlinkChainLocator(logger *logrus.Logger, root string) Locator { + l := symlinkChain{ + file: newFileLocator(logger, root), + } + + return &l +} + +// NewSymlinkLocator creats a locator that can be used for locating files through symlinks. +// A logger can also be specified. +func NewSymlinkLocator(logger *logrus.Logger, root string) Locator { + l := symlink{ + file: newFileLocator(logger, root), + } + + return &l +} + +// Locate finds the specified file at the specified root. If the file is a symlink, the link is followed and all candidates +// to the final target are returned. +func (p symlinkChain) Locate(filename string) ([]string, error) { + candidates, err := p.file.Locate(filename) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return candidates, nil + } + + found := make(map[string]bool) + for len(candidates) > 0 { + candidate := candidates[0] + candidates = candidates[:len(candidates)-1] + if found[candidate] { + continue + } + found[candidate] = true + + info, err := os.Lstat(candidate) + if err != nil { + return nil, fmt.Errorf("failed to get file info: %v", info) + } + if info.Mode()&os.ModeSymlink == 0 { + continue + } + target, err := os.Readlink(candidate) + if err != nil { + return nil, fmt.Errorf("error checking symlink: %v", err) + } + + if !filepath.IsAbs(target) { + target, err = filepath.Abs(filepath.Join(filepath.Dir(candidate), target)) + if err != nil { + return nil, fmt.Errorf("failed to construct absolute path: %v", err) + } + } + + p.logger.Debugf("Resolved link: '%v' => '%v'", candidate, target) + if !found[target] { + candidates = append(candidates, target) + } + } + + var filenames []string + for f := range found { + filenames = append(filenames, f) + } + return filenames, nil +} + +// Locate finds the specified file at the specified root. If the file is a symlink, the link is resolved and the target returned. +func (p symlink) Locate(filename string) ([]string, error) { + candidates, err := p.file.Locate(filename) + if err != nil { + return nil, err + } + if len(candidates) != 1 { + return nil, fmt.Errorf("failed to uniquely resolve symlink %v: %v", filename, candidates) + } + + target, err := filepath.EvalSymlinks(candidates[0]) + if err != nil { + return nil, fmt.Errorf("failed to resolve link: %v", err) + } + + return []string{target}, err +} diff --git a/test/input/csv_samples/.csv b/test/input/csv_samples/.csv new file mode 100644 index 00000000..e69de29b diff --git a/test/input/csv_samples/empty/.gitignore b/test/input/csv_samples/empty/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/test/input/csv_samples/jetson.csv b/test/input/csv_samples/jetson.csv new file mode 100644 index 00000000..99a573cc --- /dev/null +++ b/test/input/csv_samples/jetson.csv @@ -0,0 +1,171 @@ +dev, nvidiactl +dev, nvhost-gpu +dev, nvhost-ctrl +dev, nvhost-nvdec +dev, nvhost-ctrl-gpu +dev, nvhost-prof-gpu +dev, nvhost-dbg-gpu +dev, nvmap +dev, tegra_dc_ctrl +dev, tegra_dc_0 +dev, tegra_dc_1 +dev, nvhost-vic +dev, nvhost-as-gpu +dir, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/include +dir, /usr/lib/aarch64-linux-gnu/tegra-egl +dir, /usr/src/tensorrt +dir, /usr/local/cuda +lib, /usr/lib/aarch64-linux-gnu/libv4l/plugins/libv4l2_nvvidconv.so +lib, /usr/lib/aarch64-linux-gnu/libv4l/plugins/libv4l2_nvvideocodec.so +lib, /usr/lib/aarch64-linux-gnu/libv4l1.so.0 +lib, /usr/lib/aarch64-linux-gnu/libv4l2.so +lib, /usr/lib/aarch64-linux-gnu/libv4lconvert.so.0 +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvarguscamerasrc.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvcompositor.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvdrmvideosink.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnveglglessink.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnveglstreamsrc.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvegltransform.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvivafilter.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvjpeg.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvtee.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvvidconv.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvvideo4linux2.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvvideocuda.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvvideosink.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstnvvideosinks.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstomx.so +lib, /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstpulseaudio.so +lib, /usr/lib/aarch64-linux-gnu/libgstnvivameta.so +lib, /usr/lib/aarch64-linux-gnu/libgstnvexifmeta.so +lib, /usr/lib/aarch64-linux-gnu/libgstnvegl-1.0.so.0 +lib, /usr/lib/aarch64-linux-gnu/libnvonnxparser.so +lib, /usr/lib/aarch64-linux-gnu/libnvinfer.so +lib, /usr/lib/aarch64-linux-gnu/libnvinfer_plugin.so +lib, /usr/lib/aarch64-linux-gnu/libnvparsers.so +lib, /usr/lib/aarch64-linux-gnu/libcudnn.so +lib, /usr/lib/aarch64-linux-gnu/libnvsample_cudaprocess.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libdrm.so.2 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvapputil.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvargus.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvargus_socketclient.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvargus_socketserver.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvavp.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvbuf_utils.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcam_imageencoder.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcameratools.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcamerautils.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcamlog.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcamv4l2.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvcolorutil.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvdc.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvddk_2d_v2.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvddk_vic.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnveglstream_camconsumer.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnveglstreamproducer.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnveventlib.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvexif.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvfnet.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvfnetstoredefog.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvfnetstorehdfx.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_boot.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_camera.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_force.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_generic.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_gpucompute.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_graphics.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_il.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_spincircle.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_tbc.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvgov_ui.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvid_mapper.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-egl-wayland.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-eglcore.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-fatbinaryloader.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-glcore.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-glsi.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-glvkspirv.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-ptxjitcompiler.so.1 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-rmapi-tegra.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvidia-tls.so.32.1.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvimp.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvjpeg.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvll.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmedia.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmm.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmm_contentpipe.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmm_parser.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmm_utils.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmmlite.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmmlite_image.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmmlite_utils.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvmmlite_video.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvodm_imager.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvomx.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvomxilclient.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvos.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvosd.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvparser.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvphs.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvphsd.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvrm.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvrm_gpu.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvrm_graphics.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvscf.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvtestresults.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvtnr.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvtracebuf.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvtvmr.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvtx_helper.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libnvwinsys.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libsensors.hal-client.nvs.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libsensors.l4t.no_fusion.nvs.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libsensors_hal.nvs.so +lib, /usr/lib/aarch64-linux-gnu/tegra/libtegrav4l2.so +lib, /usr/lib/aarch64-linux-gnu/tegra/nvidia_icd.json +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/EGLWLInputEventExample +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/EGLWLMockNavigation +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/LayerManagerControl +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/desktop-shell.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/drm-backend.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/eglstream-backend.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/gl-renderer.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/hmi-controller.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/ivi-controller.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/ivi-shell.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/libilmClient.so.2.0.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/libilmCommon.so.2.0.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/libilmControl.so.2.0.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/libilmInput.so.2.0.0 +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/libinput.so.10.10.1 +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/spring-tool +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/wayland-backend.so +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-calibrator +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-clickdot +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-cliptest +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-desktop-shell +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-dnd +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-eventdemo +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-flower +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-fullscreen +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-image +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-info +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-ivi-shell-user-interface +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-keyboard +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-launch +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-multi-resource +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-resizor +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-scaler +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-screenshooter +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-simple-egl +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-simple-shm +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-simple-touch +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-smoke +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-stacking +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-subsurfaces +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-terminal +lib, /usr/lib/aarch64-linux-gnu/tegra/weston/weston-transformed +lib, /usr/lib/libvisionworks_tracking.so.0.88 +lib, /usr/lib/libvisionworks_sfm.so.0.90 +lib, /usr/lib/libvisionworks.so diff --git a/test/input/csv_samples/other.not_csv b/test/input/csv_samples/other.not_csv new file mode 100644 index 00000000..e69de29b diff --git a/test/input/csv_samples/simple.csv b/test/input/csv_samples/simple.csv new file mode 100644 index 00000000..8469c422 --- /dev/null +++ b/test/input/csv_samples/simple.csv @@ -0,0 +1,6 @@ +lib,/lib/target +dir,/lib/target +dev,/dev/null +dev,full +dev,/dev/target +sym,/source diff --git a/test/input/csv_samples/simple_wrong.csv b/test/input/csv_samples/simple_wrong.csv new file mode 100644 index 00000000..0d2ecd7f --- /dev/null +++ b/test/input/csv_samples/simple_wrong.csv @@ -0,0 +1 @@ +dir diff --git a/test/input/csv_samples/spaced.csv b/test/input/csv_samples/spaced.csv new file mode 100644 index 00000000..83dd46b0 --- /dev/null +++ b/test/input/csv_samples/spaced.csv @@ -0,0 +1,9 @@ + dev , /dev/target +lib, /lib/target + +dir,/lib/target + +sym, /source + + +