diff --git a/cmd/nvidia-ctk/README.md b/cmd/nvidia-ctk/README.md index 1080e2f5..6a94a082 100644 --- a/cmd/nvidia-ctk/README.md +++ b/cmd/nvidia-ctk/README.md @@ -1,3 +1,17 @@ # NVIDIA Container Toolkit CLI The NVIDIA Container Toolkit CLI `nvidia-ctk` provides a number of utilities that are useful for working with the NVIDIA Container Toolkit. + +## Functionality + +### Configure runtimes + +The `runtime` command of the `nvidia-ctk` CLI provides a set of utilities to related to the configuration +and management of supported container engines. + +For example, running the following command: +```bash +nvidia-ctk runtime configure --set-as-default +``` +will ensure that the NVIDIA Container Runtime is added as the default runtime to the default container +engine. diff --git a/cmd/nvidia-ctk/main.go b/cmd/nvidia-ctk/main.go index c635e3cc..ba07a719 100644 --- a/cmd/nvidia-ctk/main.go +++ b/cmd/nvidia-ctk/main.go @@ -20,6 +20,7 @@ import ( "os" "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/hook" + "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/runtime" "github.com/NVIDIA/nvidia-container-toolkit/internal/info" log "github.com/sirupsen/logrus" cli "github.com/urfave/cli/v2" @@ -70,6 +71,7 @@ func main() { // Define the subcommands c.Commands = []*cli.Command{ hook.NewCommand(logger), + runtime.NewCommand(logger), } // Run the CLI diff --git a/cmd/nvidia-ctk/runtime/configure/configure.go b/cmd/nvidia-ctk/runtime/configure/configure.go new file mode 100644 index 00000000..6788b751 --- /dev/null +++ b/cmd/nvidia-ctk/runtime/configure/configure.go @@ -0,0 +1,154 @@ +/** +# 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 configure + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/runtime/nvidia" + "github.com/NVIDIA/nvidia-container-toolkit/internal/config/docker" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +const ( + defaultRuntime = "docker" + + defaultDockerConfigFilePath = "/etc/docker/daemon.json" +) + +type command struct { + logger *logrus.Logger +} + +// NewCommand constructs an configure command with the specified logger +func NewCommand(logger *logrus.Logger) *cli.Command { + c := command{ + logger: logger, + } + return c.build() +} + +// config defines the options that can be set for the CLI through config files, +// environment variables, or command line config +type config struct { + dryRun bool + runtime string + configFilePath string + nvidiaOptions nvidia.Options +} + +func (m command) build() *cli.Command { + // Create a config struct to hold the parsed environment variables or command line flags + config := config{} + + // Create the 'configure' command + configure := cli.Command{ + Name: "configure", + Usage: "Add a runtime to the specified container engine", + Action: func(c *cli.Context) error { + return m.configureWrapper(c, &config) + }, + } + + configure.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "dry-run", + Usage: "update the runtime configuration as required but don't write changes to disk", + Destination: &config.dryRun, + }, + &cli.StringFlag{ + Name: "runtime", + Usage: "the target runtime engine. One of [docker]", + Value: defaultRuntime, + Destination: &config.runtime, + }, + &cli.StringFlag{ + Name: "config", + Usage: "path to the config file for the target runtime", + Destination: &config.configFilePath, + }, + &cli.StringFlag{ + Name: "nvidia-runtime-name", + Usage: "specify the name of the NVIDIA runtime that will be added", + Value: nvidia.RuntimeName, + Destination: &config.nvidiaOptions.RuntimeName, + }, + &cli.StringFlag{ + Name: "runtime-path", + Usage: "specify the path to the NVIDIA runtime executable", + Value: nvidia.RuntimeExecutable, + Destination: &config.nvidiaOptions.RuntimePath, + }, + &cli.BoolFlag{ + Name: "set-as-default", + Usage: "set the specified runtime as the default runtime", + Destination: &config.nvidiaOptions.SetAsDefault, + }, + } + + return &configure +} + +func (m command) configureWrapper(c *cli.Context, config *config) error { + switch config.runtime { + case "docker": + return m.configureDocker(c, config) + } + + return fmt.Errorf("unrecognized runtime '%v'", config.runtime) +} + +// configureDocker updates the docker config to enable the NVIDIA Container Runtime +func (m command) configureDocker(c *cli.Context, config *config) error { + configFilePath := config.configFilePath + if configFilePath == "" { + configFilePath = defaultDockerConfigFilePath + } + + cfg, err := docker.LoadConfig(configFilePath) + if err != nil { + return fmt.Errorf("unable to load config: %v", err) + } + + defaultRuntime := config.nvidiaOptions.DefaultRuntime() + runtimeConfig := config.nvidiaOptions.Runtime().DockerRuntimesConfig() + err = docker.UpdateConfig(cfg, defaultRuntime, runtimeConfig) + if err != nil { + return fmt.Errorf("unable to update config: %v", err) + } + + if config.dryRun { + output, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("unable to convert to JSON: %v", err) + } + os.Stdout.WriteString(fmt.Sprintf("%s\n", output)) + return nil + } + err = docker.FlushConfig(cfg, configFilePath) + if err != nil { + return fmt.Errorf("unable to flush config: %v", err) + } + + m.logger.Infof("Wrote updated config to %v", configFilePath) + m.logger.Infof("It is recommended that the docker daemon be restarted.") + + return nil +} diff --git a/cmd/nvidia-ctk/runtime/nvidia/nvidia.go b/cmd/nvidia-ctk/runtime/nvidia/nvidia.go new file mode 100644 index 00000000..1f425146 --- /dev/null +++ b/cmd/nvidia-ctk/runtime/nvidia/nvidia.go @@ -0,0 +1,75 @@ +/* +# 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 nvidia + +const ( + // RuntimeName is the default name to use in configs for the NVIDIA Container Runtime + RuntimeName = "nvidia" + // RuntimeExecutable is the default NVIDIA Container Runtime executable file name + RuntimeExecutable = "nvidia-container-runtime" +) + +// Options specifies the options for the NVIDIA Container Runtime w.r.t a container engine such as docker. +type Options struct { + SetAsDefault bool + RuntimeName string + RuntimePath string +} + +// Runtime defines an NVIDIA runtime with a name and a executable +type Runtime struct { + Name string + Path string +} + +// DefaultRuntime returns the default runtime for the configured options. +// If the configuration is invalid or the default runtimes should not be set +// the empty string is returned. +func (o Options) DefaultRuntime() string { + if !o.SetAsDefault { + return "" + } + + return o.RuntimeName +} + +// Runtime creates a runtime struct based on the options. +func (o Options) Runtime() Runtime { + path := o.RuntimePath + + if o.RuntimePath == "" { + path = RuntimeExecutable + } + + r := Runtime{ + Name: o.RuntimeName, + Path: path, + } + + return r +} + +// DockerRuntimesConfig generatest the expected docker config for the specified runtime +func (r Runtime) DockerRuntimesConfig() map[string]interface{} { + runtimes := make(map[string]interface{}) + runtimes[r.Name] = map[string]interface{}{ + "path": r.Path, + "args": []string{}, + } + + return runtimes +} diff --git a/cmd/nvidia-ctk/runtime/runtime.go b/cmd/nvidia-ctk/runtime/runtime.go new file mode 100644 index 00000000..e760f30a --- /dev/null +++ b/cmd/nvidia-ctk/runtime/runtime.go @@ -0,0 +1,49 @@ +/** +# 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 runtime + +import ( + "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/runtime/configure" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +type runtimeCommand struct { + logger *logrus.Logger +} + +// NewCommand constructs a runtime command with the specified logger +func NewCommand(logger *logrus.Logger) *cli.Command { + c := runtimeCommand{ + logger: logger, + } + return c.build() +} + +func (m runtimeCommand) build() *cli.Command { + // Create the 'runtime' command + runtime := cli.Command{ + Name: "runtime", + Usage: "A collection of runtime-related utilities for the NVIDIA Container Toolkit", + } + + runtime.Subcommands = []*cli.Command{ + configure.NewCommand(m.logger), + } + + return &runtime +} diff --git a/internal/config/docker/docker.go b/internal/config/docker/docker.go new file mode 100644 index 00000000..efe65aef --- /dev/null +++ b/internal/config/docker/docker.go @@ -0,0 +1,115 @@ +/** +# 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 docker + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + + log "github.com/sirupsen/logrus" +) + +// LoadConfig loads the docker config from disk +func LoadConfig(configFilePath string) (map[string]interface{}, error) { + log.Infof("Loading docker config from %v", configFilePath) + + info, err := os.Stat(configFilePath) + if os.IsExist(err) && info.IsDir() { + return nil, fmt.Errorf("config file is a directory") + } + + cfg := make(map[string]interface{}) + + if os.IsNotExist(err) { + log.Infof("Config file does not exist, creating new one") + return cfg, nil + } + + readBytes, err := ioutil.ReadFile(configFilePath) + if err != nil { + return nil, fmt.Errorf("unable to read config: %v", err) + } + + reader := bytes.NewReader(readBytes) + if err := json.NewDecoder(reader).Decode(&cfg); err != nil { + return nil, err + } + + log.Infof("Successfully loaded config") + return cfg, nil +} + +// UpdateConfig updates the docker config to include the nvidia runtimes +func UpdateConfig(config map[string]interface{}, defaultRuntime string, newRuntimes map[string]interface{}) error { + if defaultRuntime != "" { + config["default-runtime"] = defaultRuntime + } + + // Read the existing runtimes + runtimes := make(map[string]interface{}) + if _, exists := config["runtimes"]; exists { + runtimes = config["runtimes"].(map[string]interface{}) + } + + // Add / update the runtime definitions + for name, rt := range newRuntimes { + runtimes[name] = rt + } + + // Update the runtimes definition + if len(runtimes) > 0 { + config["runtimes"] = runtimes + } + return nil +} + +// FlushConfig flushes the updated/reverted config out to disk +func FlushConfig(cfg map[string]interface{}, configFilePath string) error { + log.Infof("Flushing docker config to %v", configFilePath) + + output, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("unable to convert to JSON: %v", err) + } + + switch len(output) { + case 0: + err := os.Remove(configFilePath) + if err != nil { + return fmt.Errorf("unable to remove empty file: %v", err) + } + log.Infof("Config empty, removing file") + default: + f, err := os.Create(configFilePath) + if err != nil { + return fmt.Errorf("unable to open %v for writing: %v", configFilePath, err) + } + defer f.Close() + + _, err = f.WriteString(string(output)) + if err != nil { + return fmt.Errorf("unable to write output: %v", err) + } + } + + log.Infof("Successfully flushed config") + + return nil +} diff --git a/internal/config/docker/docker_test.go b/internal/config/docker/docker_test.go new file mode 100644 index 00000000..9c4e961e --- /dev/null +++ b/internal/config/docker/docker_test.go @@ -0,0 +1,228 @@ +/** +# 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 docker + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateConfigDefaultRuntime(t *testing.T) { + testCases := []struct { + config map[string]interface{} + defaultRuntime string + runtimeName string + expectedDefaultRuntimeName interface{} + }{ + { + defaultRuntime: "", + expectedDefaultRuntimeName: nil, + }, + { + defaultRuntime: "NAME", + expectedDefaultRuntimeName: "NAME", + }, + { + config: map[string]interface{}{ + "default-runtime": "ALREADY_SET", + }, + defaultRuntime: "", + expectedDefaultRuntimeName: "ALREADY_SET", + }, + { + config: map[string]interface{}{ + "default-runtime": "ALREADY_SET", + }, + defaultRuntime: "NAME", + expectedDefaultRuntimeName: "NAME", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + if tc.config == nil { + tc.config = make(map[string]interface{}) + } + err := UpdateConfig(tc.config, tc.defaultRuntime, nil) + require.NoError(t, err) + + defaultRuntimeName := tc.config["default-runtime"] + require.EqualValues(t, tc.expectedDefaultRuntimeName, defaultRuntimeName) + }) + } +} + +func TestUpdateConfigRuntimes(t *testing.T) { + testCases := []struct { + config map[string]interface{} + runtimes map[string]interface{} + expectedConfig map[string]interface{} + }{ + { + config: map[string]interface{}{}, + runtimes: map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + "runtime2": map[string]interface{}{ + "path": "/test/runtime/dir/runtime2", + "args": []string{}, + }, + }, + expectedConfig: map[string]interface{}{ + "runtimes": map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + "runtime2": map[string]interface{}{ + "path": "/test/runtime/dir/runtime2", + "args": []string{}, + }, + }, + }, + }, + { + config: map[string]interface{}{ + "runtimes": map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "runtime1", + "args": []string{}, + }, + }, + }, + runtimes: map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + "runtime2": map[string]interface{}{ + "path": "/test/runtime/dir/runtime2", + "args": []string{}, + }, + }, + expectedConfig: map[string]interface{}{ + "runtimes": map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + "runtime2": map[string]interface{}{ + "path": "/test/runtime/dir/runtime2", + "args": []string{}, + }, + }, + }, + }, + { + config: map[string]interface{}{ + "runtimes": map[string]interface{}{ + "not-nvidia": map[string]interface{}{ + "path": "some-other-path", + "args": []string{}, + }, + }, + }, + runtimes: map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + }, + expectedConfig: map[string]interface{}{ + "runtimes": map[string]interface{}{ + "not-nvidia": map[string]interface{}{ + "path": "some-other-path", + "args": []string{}, + }, + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + }, + }, + }, + { + config: map[string]interface{}{ + "exec-opts": []string{"native.cgroupdriver=systemd"}, + "log-driver": "json-file", + "log-opts": map[string]string{ + "max-size": "100m", + }, + "storage-driver": "overlay2", + }, + runtimes: map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + }, + expectedConfig: map[string]interface{}{ + "exec-opts": []string{"native.cgroupdriver=systemd"}, + "log-driver": "json-file", + "log-opts": map[string]string{ + "max-size": "100m", + }, + "storage-driver": "overlay2", + "runtimes": map[string]interface{}{ + "runtime1": map[string]interface{}{ + "path": "/test/runtime/dir/runtime1", + "args": []string{}, + }, + }, + }, + }, + { + config: map[string]interface{}{ + "exec-opts": []string{"native.cgroupdriver=systemd"}, + "log-driver": "json-file", + "log-opts": map[string]string{ + "max-size": "100m", + }, + "storage-driver": "overlay2", + }, + expectedConfig: map[string]interface{}{ + "exec-opts": []string{"native.cgroupdriver=systemd"}, + "log-driver": "json-file", + "log-opts": map[string]string{ + "max-size": "100m", + }, + "storage-driver": "overlay2", + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) { + err := UpdateConfig(tc.config, "", tc.runtimes) + require.NoError(t, err) + + configContent, err := json.MarshalIndent(tc.config, "", " ") + require.NoError(t, err) + + expectedContent, err := json.MarshalIndent(tc.expectedConfig, "", " ") + require.NoError(t, err) + + require.EqualValues(t, string(expectedContent), string(configContent)) + }) + + } +} diff --git a/tools/container/docker/docker.go b/tools/container/docker/docker.go index 21866a1b..ce6e7dca 100644 --- a/tools/container/docker/docker.go +++ b/tools/container/docker/docker.go @@ -17,16 +17,14 @@ package main import ( - "bytes" - "encoding/json" "fmt" - "io/ioutil" "net" "os" "path/filepath" "syscall" "time" + "github.com/NVIDIA/nvidia-container-toolkit/internal/config/docker" log "github.com/sirupsen/logrus" cli "github.com/urfave/cli/v2" ) @@ -247,52 +245,15 @@ func ParseArgs(c *cli.Context) (string, error) { // LoadConfig loads the docker config from disk func LoadConfig(config string) (map[string]interface{}, error) { - log.Infof("Loading config: %v", config) - - info, err := os.Stat(config) - if os.IsExist(err) && info.IsDir() { - return nil, fmt.Errorf("config file is a directory") - } - - cfg := make(map[string]interface{}) - - if os.IsNotExist(err) { - log.Infof("Config file does not exist, creating new one") - return cfg, nil - } - - readBytes, err := ioutil.ReadFile(config) - if err != nil { - return nil, fmt.Errorf("unable to read config: %v", err) - } - - reader := bytes.NewReader(readBytes) - if err := json.NewDecoder(reader).Decode(&cfg); err != nil { - return nil, err - } - - log.Infof("Successfully loaded config") - return cfg, nil + return docker.LoadConfig(config) } // UpdateConfig updates the docker config to include the nvidia runtimes func UpdateConfig(config map[string]interface{}, o *options) error { defaultRuntime := o.getDefaultRuntime() - if defaultRuntime != "" { - config["default-runtime"] = defaultRuntime - } + runtimes := o.runtimes() - runtimes := make(map[string]interface{}) - if _, exists := config["runtimes"]; exists { - runtimes = config["runtimes"].(map[string]interface{}) - } - - for name, rt := range o.runtimes() { - runtimes[name] = rt - } - - config["runtimes"] = runtimes - return nil + return docker.UpdateConfig(config, defaultRuntime, runtimes) } //RevertConfig reverts the docker config to remove the nvidia runtime @@ -320,36 +281,7 @@ func RevertConfig(config map[string]interface{}) error { // FlushConfig flushes the updated/reverted config out to disk func FlushConfig(cfg map[string]interface{}, config string) error { - log.Infof("Flushing config") - - output, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("unable to convert to JSON: %v", err) - } - - switch len(output) { - case 0: - err := os.Remove(config) - if err != nil { - return fmt.Errorf("unable to remove empty file: %v", err) - } - log.Infof("Config empty, removing file") - default: - f, err := os.Create(config) - if err != nil { - return fmt.Errorf("unable to open %v for writing: %v", config, err) - } - defer f.Close() - - _, err = f.WriteString(string(output)) - if err != nil { - return fmt.Errorf("unable to write output: %v", err) - } - } - - log.Infof("Successfully flushed config") - - return nil + return docker.FlushConfig(cfg, config) } // RestartDocker restarts docker depending on the value of restartModeFlag