Skip to content

Commit

Permalink
containers.conf: implement modules
Browse files Browse the repository at this point in the history
Add a new concept to containers.conf called "modules".  A "module" is
a containers.conf file located at a specific directory.  More than one
modules can be loaded in the specified order, following existing
override semantics.

There are three directories to load modules from:
 - $CONFIG_HOME/containers/containers.conf.modules
 - /etc/containers/containers.conf.modules
 - /usr/share/containers/containers.conf.modules

With CONFIG_HOME pointing to $HOME/.config or, if set, $XDG_CONFIG_HOME.
Absolute paths will be loaded as is, relative paths will be resolved
relative to the three directories above allowing for admin configs
(/etc/) to override system configs (/usr/share/) and user configs
($CONFIG_HOME) to override admin configs.

Also move some functions from config.go for locality.

Signed-off-by: Valentin Rothberg <[email protected]>
  • Loading branch information
vrothberg committed Aug 8, 2023
1 parent 1790204 commit ff4843b
Show file tree
Hide file tree
Showing 20 changed files with 425 additions and 116 deletions.
94 changes: 0 additions & 94 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ package config
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"

"github.com/BurntSushi/toml"
Expand Down Expand Up @@ -707,98 +705,6 @@ func (c *EngineConfig) ImagePlatformToRuntime(os string, arch string) string {
return c.OCIRuntime
}

// readConfigFromFile reads the specified config file at `path` and attempts to
// unmarshal its content into a Config. The config param specifies the previous
// default config. If the path, only specifies a few fields in the Toml file
// the defaults from the config parameter will be used for all other fields.
func readConfigFromFile(path string, config *Config) error {
logrus.Tracef("Reading configuration file %q", path)
meta, err := toml.DecodeFile(path, config)
if err != nil {
return fmt.Errorf("decode configuration %v: %w", path, err)
}
keys := meta.Undecoded()
if len(keys) > 0 {
logrus.Debugf("Failed to decode the keys %q from %q.", keys, path)
}

return nil
}

// addConfigs will search one level in the config dirPath for config files
// If the dirPath does not exist, addConfigs will return nil
func addConfigs(dirPath string, configs []string) ([]string, error) {
newConfigs := []string{}

err := filepath.WalkDir(dirPath,
// WalkFunc to read additional configs
func(path string, d fs.DirEntry, err error) error {
switch {
case err != nil:
// return error (could be a permission problem)
return err
case d.IsDir():
if path != dirPath {
// make sure to not recurse into sub-directories
return filepath.SkipDir
}
// ignore directories
return nil
default:
// only add *.conf files
if strings.HasSuffix(path, ".conf") {
newConfigs = append(newConfigs, path)
}
return nil
}
},
)
if errors.Is(err, os.ErrNotExist) {
err = nil
}
sort.Strings(newConfigs)
return append(configs, newConfigs...), err
}

// Returns the list of configuration files, if they exist in order of hierarchy.
// The files are read in order and each new file can/will override previous
// file settings.
func systemConfigs() (configs []string, finalErr error) {
if path := os.Getenv("CONTAINERS_CONF"); path != "" {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("CONTAINERS_CONF file: %w", err)
}
return append(configs, path), nil
}
if _, err := os.Stat(DefaultContainersConfig); err == nil {
configs = append(configs, DefaultContainersConfig)
}
if _, err := os.Stat(OverrideContainersConfig); err == nil {
configs = append(configs, OverrideContainersConfig)
}

var err error
configs, err = addConfigs(OverrideContainersConfig+".d", configs)
if err != nil {
return nil, err
}

path, err := ifRootlessConfigPath()
if err != nil {
return nil, err
}
if path != "" {
if _, err := os.Stat(path); err == nil {
configs = append(configs, path)
}
configs, err = addConfigs(path+".d", configs)
if err != nil {
return nil, err
}
}
return configs, nil
}

// CheckCgroupsAndAdjustConfig checks if we're running rootless with the systemd
// cgroup manager. In case the user session isn't available, we're switching the
// cgroup manager to cgroupfs. Note, this only applies to rootless.
Expand Down
88 changes: 88 additions & 0 deletions pkg/config/modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package config

import (
"fmt"
"os"
"path/filepath"

"github.com/containers/storage/pkg/homedir"
"github.com/containers/storage/pkg/unshare"
"github.com/hashicorp/go-multierror"
)

// The subdirectory for looking up containers.conf modules.
const moduleSubdir = "containers/containers.conf.modules"

// Moving the base paths into variables allows for overriding them in units
// tests.
var (
moduleBaseEtc = "/etc/"
moduleBaseUsr = "/usr/share"
)

// Find the specified modules in the options. Return an error if a specific
// module cannot be located on the host.
func (o *Options) modules() ([]string, error) {
if len(o.Modules) == 0 {
return nil, nil
}

dirs, err := moduleDirectories()
if err != nil {
return nil, err
}

configs := make([]string, 0, len(o.Modules))
for _, path := range o.Modules {
resolved, err := resolveModule(path, dirs)
if err != nil {
return nil, fmt.Errorf("could not resolve module %q: %w", path, err)
}
configs = append(configs, resolved)
}

return configs, nil
}

// Return the directories to load modules from:
// 1) XDG_CONFIG_HOME/HOME if rootless
// 2) /etc/
// 3) /usr/share
func moduleDirectories() ([]string, error) {
modules := []string{
filepath.Join(moduleBaseEtc, moduleSubdir),
filepath.Join(moduleBaseUsr, moduleSubdir),
}

if !unshare.IsRootless() {
return modules, nil
}

// Prepend the user modules dir.
configHome, err := homedir.GetConfigHome()
if err != nil {
return nil, err
}
return append([]string{filepath.Join(configHome, moduleSubdir)}, modules...), nil
}

// Resolve the specified path to a module.
func resolveModule(path string, dirs []string) (string, error) {
if filepath.IsAbs(path) {
_, err := os.Stat(path)
return path, err
}

// Collect all errors to avoid suppressing important errors (e.g.,
// permission errors).
var multiErr error
for _, d := range dirs {
candidate := filepath.Join(d, path)
_, err := os.Stat(candidate)
if err == nil {
return candidate, nil
}
multiErr = multierror.Append(multiErr, err)
}
return "", multiErr
}
169 changes: 169 additions & 0 deletions pkg/config/modules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package config

import (
"os"
"path/filepath"

"github.com/containers/storage/pkg/unshare"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

const (
testBaseHome = "testdata/modules/home/.config"
testBaseEtc = "testdata/modules/etc"
testBaseUsr = "testdata/modules/usr/share"
)

func testSetModulePaths() (func(), error) {
oldXDG := os.Getenv("XDG_CONFIG_HOME")
oldEtc := moduleBaseEtc
oldUsr := moduleBaseUsr

wd, err := os.Getwd()
if err != nil {
return nil, err
}

if err := os.Setenv("XDG_CONFIG_HOME", filepath.Join(wd, testBaseHome)); err != nil {
return nil, err
}

moduleBaseEtc = filepath.Join(wd, testBaseEtc)
moduleBaseUsr = filepath.Join(wd, testBaseUsr)

return func() {
os.Setenv("XDG_CONFIG_HOME", oldXDG)
moduleBaseEtc = oldEtc
moduleBaseUsr = oldUsr
}, nil
}

var _ = Describe("Config Modules", func() {
It("module directories", func() {
dirs, err := moduleDirectories()
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(dirs).NotTo(gomega.BeNil())

if unshare.IsRootless() {
gomega.Expect(dirs).To(gomega.HaveLen(3))
} else {
gomega.Expect(dirs).To(gomega.HaveLen(2))
}
})

It("resolve modules", func() {
// This test makes sure that the correct module is being
// returned.
cleanUp, err := testSetModulePaths()
gomega.Expect(err).To(gomega.BeNil())
defer cleanUp()

dirs, err := moduleDirectories()
gomega.Expect(err).To(gomega.BeNil())

if unshare.IsRootless() {
gomega.Expect(dirs).To(gomega.HaveLen(3))
gomega.Expect(dirs[0]).To(gomega.ContainSubstring(testBaseHome))
gomega.Expect(dirs[1]).To(gomega.ContainSubstring(testBaseEtc))
gomega.Expect(dirs[2]).To(gomega.ContainSubstring(testBaseUsr))
} else {
gomega.Expect(dirs).To(gomega.HaveLen(2))
gomega.Expect(dirs[0]).To(gomega.ContainSubstring(testBaseEtc))
gomega.Expect(dirs[1]).To(gomega.ContainSubstring(testBaseUsr))
}

for _, test := range []struct {
input string
expectedDir string
mustFail bool
rootless bool
}{
// Rootless
{"first.conf", testBaseHome, false, true},
{"second.conf", testBaseHome, false, true},
{"third.conf", testBaseHome, false, true},
{"sub/first.conf", testBaseHome, false, true},

// Root + Rootless
{"fourth.conf", testBaseEtc, false, false},
{"sub/etc-only.conf", testBaseEtc, false, false},
{"fifth.conf", testBaseUsr, false, false},
{"sub/share-only.conf", testBaseUsr, false, false},
{"none.conf", "", true, false},
} {
if test.rootless && !unshare.IsRootless() {
continue
}
result, err := resolveModule(test.input, dirs)
if test.mustFail {
gomega.Expect(err).NotTo(gomega.BeNil())
continue
}
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(result).To(gomega.HaveSuffix(filepath.Join(test.expectedDir, moduleSubdir, test.input)))
}
})

It("new config with modules", func() {
cleanUp, err := testSetModulePaths()
gomega.Expect(err).To(gomega.BeNil())
defer cleanUp()

options := &Options{Modules: []string{"none.conf"}}
_, err = New(options)
gomega.Expect(err).NotTo(gomega.BeNil()) // must error out

options = &Options{}
c, err := New(options)
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(options.additionalConfigs).To(gomega.HaveLen(0)) // no module is getting loaded!
gomega.Expect(c).NotTo(gomega.BeNil())

options = &Options{Modules: []string{"fourth.conf"}}
c, err = New(options)
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(options.additionalConfigs).To(gomega.HaveLen(1)) // 1 module is getting loaded!
gomega.Expect(c.Containers.InitPath).To(gomega.Equal("etc four"))

options = &Options{Modules: []string{"fourth.conf"}}
c, err = New(options)
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(options.additionalConfigs).To(gomega.HaveLen(1)) // 1 module is getting loaded!
gomega.Expect(c.Containers.InitPath).To(gomega.Equal("etc four"))

options = &Options{Modules: []string{"fourth.conf", "sub/share-only.conf"}}
c, err = New(options)
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(options.additionalConfigs).To(gomega.HaveLen(2)) // 2 modules are getting loaded!
gomega.Expect(c.Containers.InitPath).To(gomega.Equal("etc four"))
gomega.Expect(c.Containers.Env).To(gomega.Equal([]string{"usr share only"}))
})

It("new config with modules and env variables", func() {
cleanUp, err := testSetModulePaths()
gomega.Expect(err).To(gomega.BeNil())
defer cleanUp()

oldOverride := os.Getenv(containersConfOverrideEnv)
defer func() {
os.Setenv(containersConfOverrideEnv, oldOverride)
}()

err = os.Setenv(containersConfOverrideEnv, "testdata/modules/override.conf")
gomega.Expect(err).To(gomega.BeNil())

// Also make sure that absolute paths are loaded as is.
wd, err := os.Getwd()
gomega.Expect(err).To(gomega.BeNil())
absConf := filepath.Join(wd, "testdata/modules/home/.config/containers/containers.conf.modules/second.conf")

options := &Options{Modules: []string{"fourth.conf", "sub/share-only.conf", absConf}}
c, err := New(options)
gomega.Expect(err).To(gomega.BeNil())
gomega.Expect(options.additionalConfigs).To(gomega.HaveLen(4)) // 2 modules + abs path + override conf are getting loaded!
gomega.Expect(c.Containers.InitPath).To(gomega.Equal("etc four"))
gomega.Expect(c.Containers.Env).To(gomega.Equal([]string{"override conf always wins"}))
gomega.Expect(c.Containers.Volumes).To(gomega.Equal([]string{"home second"}))
})
})
Loading

0 comments on commit ff4843b

Please sign in to comment.