diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c2edbb..d64605dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ * Added support for generating OCI hook JSON file to `nvidia-ctk runtime configure` command. * Remove installation of OCI hook JSON from RPM package. * Refactored config for `nvidia-container-runtime-hook`. +* Added a `nvidia-ctk config` command which supports setting config options using a `--set` flag. ## v1.14.0-rc.2 - * Fix bug causing incorrect nvidia-smi symlink to be created on WSL2 systems with multiple driver roots. * Remove dependency on coreutils when installing package on RPM-based systems. * Create ouput folders if required when running `nvidia-ctk runtime configure` diff --git a/cmd/nvidia-ctk/README.md b/cmd/nvidia-ctk/README.md index b6186819..7fa8abbd 100644 --- a/cmd/nvidia-ctk/README.md +++ b/cmd/nvidia-ctk/README.md @@ -16,6 +16,31 @@ 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. +## Configure the NVIDIA Container Toolkit + +The `config` command of the `nvidia-ctk` CLI allows a user to display and manipulate the NVIDIA Container Toolkit +configuration. + +For example, running the following command: +```bash +nvidia-ctk config default +``` +will display the default config for the detected platform. + +Whereas +```bash +nvidia-ctk config +``` +will display the effective NVIDIA Container Toolkit config using the configured config file, and running: + +Individual config options can be set by specifying these are key-value pairs to the `--set` argument: + +```bash +nvidia-ctk config --set nvidia-container-cli.no-cgroups=true +``` + +By default, all commands output to `STDOUT`, but specifying the `--output` flag writes the config to the specified file. + ### Generate CDI specifications The [Container Device Interface (CDI)](https://github.com/container-orchestrated-devices/container-device-interface) provides diff --git a/cmd/nvidia-ctk/config/config.go b/cmd/nvidia-ctk/config/config.go index 2f00ed59..9fc1a107 100644 --- a/cmd/nvidia-ctk/config/config.go +++ b/cmd/nvidia-ctk/config/config.go @@ -17,7 +17,15 @@ package config import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + createdefault "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/config/create-default" + "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/config/flags" + "github.com/NVIDIA/nvidia-container-toolkit/internal/config" "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" "github.com/urfave/cli/v2" ) @@ -26,6 +34,12 @@ type command struct { logger logger.Interface } +// options stores the subcommand options +type options struct { + flags.Options + sets cli.StringSlice +} + // NewCommand constructs an config command with the specified logger func NewCommand(logger logger.Interface) *cli.Command { c := command{ @@ -36,10 +50,42 @@ func NewCommand(logger logger.Interface) *cli.Command { // build func (m command) build() *cli.Command { + opts := options{} + // Create the 'config' command c := cli.Command{ Name: "config", Usage: "Interact with the NVIDIA Container Toolkit configuration", + Action: func(ctx *cli.Context) error { + return run(ctx, &opts) + }, + } + + c.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "config-file", + Aliases: []string{"config", "c"}, + Usage: "Specify the config file to modify.", + Value: config.GetConfigFilePath(), + Destination: &opts.Config, + }, + &cli.StringSliceFlag{ + Name: "set", + Usage: "Set a config value using the pattern key=value. If value is empty, this is equivalent to specifying the same key in unset. This flag can be specified multiple times", + Destination: &opts.sets, + }, + &cli.BoolFlag{ + Name: "in-place", + Aliases: []string{"i"}, + Usage: "Modify the config file in-place", + Destination: &opts.InPlace, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Specify the output file to write to; If not specified, the output is written to stdout", + Destination: &opts.Output, + }, } c.Subcommands = []*cli.Command{ @@ -48,3 +94,71 @@ func (m command) build() *cli.Command { return &c } + +func run(c *cli.Context, opts *options) error { + cfgToml, err := config.New( + config.WithConfigFile(opts.Config), + ) + if err != nil { + return fmt.Errorf("unable to create config: %v", err) + } + + for _, set := range opts.sets.Value() { + key, value, err := (*configToml)(cfgToml).setFlagToKeyValue(set) + if err != nil { + return fmt.Errorf("invalid --set option %v: %w", set, err) + } + cfgToml.Set(key, value) + } + + cfgToml.Save(os.Stdout) + return nil +} + +type configToml config.Toml + +var errInvalidConfigOption = errors.New("invalid config option") +var errInvalidFormat = errors.New("invalid format") + +// setFlagToKeyValue converts a --set flag to a key-value pair. +// The set flag is of the form key[=value], with the value being optional if key refers to a +// boolean config option. +func (c *configToml) setFlagToKeyValue(setFlag string) (string, interface{}, error) { + if c == nil { + return "", nil, errInvalidConfigOption + } + + setParts := strings.SplitN(setFlag, "=", 2) + key := setParts[0] + + v := (*config.Toml)(c).Get(key) + if v == nil { + return key, nil, errInvalidConfigOption + } + switch v.(type) { + case bool: + if len(setParts) == 1 { + return key, true, nil + } + } + + if len(setParts) != 2 { + return key, nil, fmt.Errorf("%w: expected key=value; got %v", errInvalidFormat, setFlag) + } + + value := setParts[1] + switch vt := v.(type) { + case bool: + b, err := strconv.ParseBool(value) + if err != nil { + return key, value, fmt.Errorf("%w: %w", errInvalidFormat, err) + } + return key, b, err + case string: + return key, value, nil + case []string: + return key, strings.Split(value, ","), nil + default: + return key, nil, fmt.Errorf("unsupported type for %v (%v)", setParts, vt) + } +} diff --git a/cmd/nvidia-ctk/config/config_test.go b/cmd/nvidia-ctk/config/config_test.go new file mode 100644 index 00000000..bab1cb4d --- /dev/null +++ b/cmd/nvidia-ctk/config/config_test.go @@ -0,0 +1,173 @@ +/** +# Copyright (c) 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 config + +import ( + "testing" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/config" + "github.com/pelletier/go-toml" + "github.com/stretchr/testify/require" +) + +func TestSetFlagToKeyValue(t *testing.T) { + testCases := []struct { + description string + config map[string]interface{} + setFlag string + expectedKey string + expectedValue interface{} + expectedError error + }{ + { + description: "empty config returns an error", + setFlag: "anykey=value", + expectedKey: "anykey", + expectedError: errInvalidConfigOption, + }, + { + description: "option not present returns an error", + config: map[string]interface{}{ + "defined": "defined-value", + }, + setFlag: "undefined=new-value", + expectedKey: "undefined", + expectedError: errInvalidConfigOption, + }, + { + description: "boolean option assumes true", + config: map[string]interface{}{ + "boolean": false, + }, + setFlag: "boolean", + expectedKey: "boolean", + expectedValue: true, + }, + { + description: "boolean option returns true", + config: map[string]interface{}{ + "boolean": false, + }, + setFlag: "boolean=true", + expectedKey: "boolean", + expectedValue: true, + }, + { + description: "boolean option returns false", + config: map[string]interface{}{ + "boolean": false, + }, + setFlag: "boolean=false", + expectedKey: "boolean", + expectedValue: false, + }, + { + description: "invalid boolean option returns error", + config: map[string]interface{}{ + "boolean": false, + }, + setFlag: "boolean=something", + expectedKey: "boolean", + expectedValue: "something", + expectedError: errInvalidFormat, + }, + { + description: "string option requires value", + config: map[string]interface{}{ + "string": "value", + }, + setFlag: "string", + expectedKey: "string", + expectedValue: nil, + expectedError: errInvalidFormat, + }, + { + description: "string option returns value", + config: map[string]interface{}{ + "string": "value", + }, + setFlag: "string=string-value", + expectedKey: "string", + expectedValue: "string-value", + }, + { + description: "string option returns value with equals", + config: map[string]interface{}{ + "string": "value", + }, + setFlag: "string=string-value=more", + expectedKey: "string", + expectedValue: "string-value=more", + }, + { + description: "string option treats bool value as string", + config: map[string]interface{}{ + "string": "value", + }, + setFlag: "string=true", + expectedKey: "string", + expectedValue: "true", + }, + { + description: "string option treats int value as string", + config: map[string]interface{}{ + "string": "value", + }, + setFlag: "string=5", + expectedKey: "string", + expectedValue: "5", + }, + { + description: "[]string option returns single value", + config: map[string]interface{}{ + "string": []string{"value"}, + }, + setFlag: "string=string-value", + expectedKey: "string", + expectedValue: []string{"string-value"}, + }, + { + description: "[]string option returns multiple values", + config: map[string]interface{}{ + "string": []string{"value"}, + }, + setFlag: "string=first,second", + expectedKey: "string", + expectedValue: []string{"first", "second"}, + }, + { + description: "[]string option returns values with equals", + config: map[string]interface{}{ + "string": []string{"value"}, + }, + setFlag: "string=first=1,second=2", + expectedKey: "string", + expectedValue: []string{"first=1", "second=2"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + tree, _ := toml.TreeFromMap(tc.config) + cfgToml := (*config.Toml)(tree) + k, v, err := (*configToml)(cfgToml).setFlagToKeyValue(tc.setFlag) + require.ErrorIs(t, err, tc.expectedError) + require.EqualValues(t, tc.expectedKey, k) + require.EqualValues(t, tc.expectedValue, v) + }) + } +} diff --git a/cmd/nvidia-ctk/config/create-default/create-default.go b/cmd/nvidia-ctk/config/create-default/create-default.go index 584a9db3..d8c8b00f 100644 --- a/cmd/nvidia-ctk/config/create-default/create-default.go +++ b/cmd/nvidia-ctk/config/create-default/create-default.go @@ -18,10 +18,8 @@ package defaultsubcommand import ( "fmt" - "io" - "os" - "path/filepath" + "github.com/NVIDIA/nvidia-container-toolkit/cmd/nvidia-ctk/config/flags" "github.com/NVIDIA/nvidia-container-toolkit/internal/config" "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" "github.com/urfave/cli/v2" @@ -31,11 +29,6 @@ type command struct { logger logger.Interface } -// options stores the subcommand options -type options struct { - output string -} - // NewCommand constructs a default command with the specified logger func NewCommand(logger logger.Interface) *cli.Command { c := command{ @@ -46,7 +39,7 @@ func NewCommand(logger logger.Interface) *cli.Command { // build creates the CLI command func (m command) build() *cli.Command { - opts := options{} + opts := flags.Options{} // Create the 'default' command c := cli.Command{ @@ -66,24 +59,24 @@ func (m command) build() *cli.Command { Name: "output", Aliases: []string{"o"}, Usage: "Specify the output file to write to; If not specified, the output is written to stdout", - Destination: &opts.output, + Destination: &opts.Output, }, } return &c } -func (m command) validateFlags(c *cli.Context, opts *options) error { - return nil +func (m command) validateFlags(c *cli.Context, opts *flags.Options) error { + return opts.Validate() } -func (m command) run(c *cli.Context, opts *options) error { +func (m command) run(c *cli.Context, opts *flags.Options) error { cfgToml, err := config.New() if err != nil { return fmt.Errorf("unable to load or create config: %v", err) } - if err := opts.ensureOutputFolder(); err != nil { + if err := opts.EnsureOutputFolder(); err != nil { return fmt.Errorf("failed to create output directory: %v", err) } output, err := opts.CreateOutput() @@ -99,31 +92,3 @@ func (m command) run(c *cli.Context, opts *options) error { return nil } - -// ensureOutputFolder creates the output folder if it does not exist. -// If the output folder is not specified (i.e. output to STDOUT), it is ignored. -func (o options) ensureOutputFolder() error { - if o.output == "" { - return nil - } - if dir := filepath.Dir(o.output); dir != "" { - return os.MkdirAll(dir, 0755) - } - return nil -} - -func (o options) CreateOutput() (io.WriteCloser, error) { - if o.output != "" { - return os.Create(o.output) - } - - return nullCloser{os.Stdout}, nil -} - -type nullCloser struct { - io.Writer -} - -func (d nullCloser) Close() error { - return nil -} diff --git a/cmd/nvidia-ctk/config/flags/options.go b/cmd/nvidia-ctk/config/flags/options.go new file mode 100644 index 00000000..95cdc4c2 --- /dev/null +++ b/cmd/nvidia-ctk/config/flags/options.go @@ -0,0 +1,71 @@ +/** +# Copyright (c) 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 flags + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// Options stores options for the config commands +type Options struct { + Config string + Output string + InPlace bool +} + +// Validate checks whether the options are valid. +func (o Options) Validate() error { + if o.InPlace && o.Output != "" { + return fmt.Errorf("cannot specify both --in-place and --output") + } + + return nil +} + +// EnsureOutputFolder creates the output folder if it does not exist. +// If the output folder is not specified (i.e. output to STDOUT), it is ignored. +func (o Options) EnsureOutputFolder() error { + if o.Output == "" { + return nil + } + if dir := filepath.Dir(o.Output); dir != "" { + return os.MkdirAll(dir, 0755) + } + return nil +} + +// CreateOutput creates the writer for the output. +func (o Options) CreateOutput() (io.WriteCloser, error) { + if o.Output != "" { + return os.Create(o.Output) + } + + return nullCloser{os.Stdout}, nil +} + +// nullCloser is a writer that does nothing on Close. +type nullCloser struct { + io.Writer +} + +// Close is a no-op for a nullCloser. +func (d nullCloser) Close() error { + return nil +} diff --git a/internal/config/toml.go b/internal/config/toml.go index 6183db61..8e39702a 100644 --- a/internal/config/toml.go +++ b/internal/config/toml.go @@ -148,6 +148,21 @@ func (t Toml) format(contents []byte) ([]byte, error) { return replaced, nil } +// Delete deletes the specified key from the TOML config. +func (t *Toml) Delete(key string) error { + return (*toml.Tree)(t).Delete(key) +} + +// Get returns the value for the specified key. +func (t *Toml) Get(key string) interface{} { + return (*toml.Tree)(t).Get(key) +} + +// Set sets the specified key to the specified value in the TOML config. +func (t *Toml) Set(key string, value interface{}) { + (*toml.Tree)(t).Set(key, value) +} + // commentDefaults applies the required comments for default values to the Toml. func (t *Toml) commentDefaults() *Toml { asToml := (*toml.Tree)(t) diff --git a/packaging/debian/nvidia-container-toolkit-base.postinst b/packaging/debian/nvidia-container-toolkit-base.postinst index 311af07c..7ee72e46 100644 --- a/packaging/debian/nvidia-container-toolkit-base.postinst +++ b/packaging/debian/nvidia-container-toolkit-base.postinst @@ -4,7 +4,7 @@ set -e case "$1" in configure) - /usr/bin/nvidia-ctk --quiet config default --in-place --config=/etc/nvidia-container-runtime/config.toml + /usr/bin/nvidia-ctk --quiet config --config-file=/etc/nvidia-container-runtime/config.toml --in-place ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/packaging/rpm/SPECS/nvidia-container-toolkit.spec b/packaging/rpm/SPECS/nvidia-container-toolkit.spec index 4d1db18d..a3613537 100644 --- a/packaging/rpm/SPECS/nvidia-container-toolkit.spec +++ b/packaging/rpm/SPECS/nvidia-container-toolkit.spec @@ -59,7 +59,7 @@ rm -rf %{_localstatedir}/lib/rpm-state/nvidia-container-toolkit ln -sf %{_bindir}/nvidia-container-runtime-hook %{_bindir}/nvidia-container-toolkit # Generate the default config; If this file already exists no changes are made. -%{_bindir}/nvidia-ctk --quiet config default --in-place --config=%{_sysconfdir}/nvidia-container-runtime/config.toml +%{_bindir}/nvidia-ctk --quiet config --config-file=%{_sysconfdir}/nvidia-container-runtime/config.toml --in-place %postun if [ "$1" = 0 ]; then # package is uninstalled, not upgraded