From 6d8b59c522f7fd9a8cddf6c127df3f6380f883b8 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Mon, 23 Mar 2026 19:35:18 +0100 Subject: [PATCH 01/10] image: no longer accept V1 syntax in registries.conf Deprecate V1RegistriesConf and no longer accept the V1 syntax as part of the config file. Signed-off-by: Paul Holzinger --- image/docs/containers-registries.conf.5.md | 18 --------- image/docs/containers-registries.conf.d.5.md | 3 -- .../sysregistriesv2/system_registries_v2.go | 34 +++++++---------- .../system_registries_v2_test.go | 38 +++---------------- 4 files changed, 19 insertions(+), 74 deletions(-) diff --git a/image/docs/containers-registries.conf.5.md b/image/docs/containers-registries.conf.5.md index ed17fff840..1079706a4f 100644 --- a/image/docs/containers-registries.conf.5.md +++ b/image/docs/containers-registries.conf.5.md @@ -284,24 +284,6 @@ The format of `$image_reference` is `$repo{:$tag|@$digest}`. Additional Layer Stores can use this helper binary to access the private registry. -## VERSION 1 FORMAT - DEPRECATED -VERSION 1 format is still supported but it does not support -using registry mirrors, longest-prefix matches, or location rewriting. - -The TOML format is used to build a simple list of registries under three -categories: `registries.search`, `registries.insecure`, and `registries.block`. -You can list multiple registries using a comma separated list. - -Search registries are used when the caller of a container runtime does not fully specify the -container image that they want to execute. These registries are prepended onto the front -of the specified container image until the named image is found at a registry. - -Note that insecure registries can be used for any registry, not just the registries listed -under search. - -The `registries.insecure` and `registries.block` lists have the same meaning as the -`insecure` and `blocked` fields in the current version. - ### EXAMPLE The following example configuration defines two searchable registries, one insecure registry, and two blocked registries. diff --git a/image/docs/containers-registries.conf.d.5.md b/image/docs/containers-registries.conf.d.5.md index 563ccfd00c..2d4f2695ff 100644 --- a/image/docs/containers-registries.conf.d.5.md +++ b/image/docs/containers-registries.conf.d.5.md @@ -26,9 +26,6 @@ settings in `/etc/containers/registries.conf`. The `[[registry]]` tables merged by overwriting existing items if the prefixes are identical while new ones are added. -All drop-in configuration files must be specified in the version 2 of the -`containers-registries.conf(5)` format. - # SEE ALSO `containers-registries.conf(5)` diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index bc063117c6..72e7ede6e2 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -185,11 +185,15 @@ func (r *Registry) PullSourcesFromReference(ref reference.Named) ([]PullSource, } // V1TOMLregistries is for backwards compatibility to sysregistries v1 +// +// Deprecated: This format is no longer accepted, use [V2RegistriesConf] instead. type V1TOMLregistries struct { Registries []string `toml:"registries"` } // V1TOMLConfig is for backwards compatibility to sysregistries v1 +// +// Deprecated: This format is no longer accepted, use [V2RegistriesConf] instead. type V1TOMLConfig struct { Search V1TOMLregistries `toml:"search"` Insecure V1TOMLregistries `toml:"insecure"` @@ -197,6 +201,9 @@ type V1TOMLConfig struct { } // V1RegistriesConf is the sysregistries v1 configuration format. +// +// Deprecated: This format is no longer accepted, use [V2RegistriesConf] instead. +// You can use [V1RegistriesConf.ConvertToV2] to convert the type. type V1RegistriesConf struct { V1TOMLConfig `toml:"registries"` } @@ -721,7 +728,7 @@ func tryUpdatingCache(ctx *types.SystemContext, wrapper configWrapper) (*parsedC defer configMutex.Unlock() // load the config - config, err := loadConfigFile(wrapper.configPath, false) + config, err := loadConfigFile(wrapper.configPath) if err != nil { // Continue with an empty []Registry if we use the default config, which // implies that the config path of the SystemContext isn't set. @@ -746,8 +753,7 @@ func tryUpdatingCache(ctx *types.SystemContext, wrapper configWrapper) (*parsedC return nil, err } for _, path := range dinConfigs { - // Enforce v2 format for drop-in-configs. - dropIn, err := loadConfigFile(path, true) + dropIn, err := loadConfigFile(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { // file must have been removed between the directory listing @@ -947,14 +953,13 @@ func findRegistryWithParsedConfig(config *parsedConfig, ref string) (*Registry, } // loadConfigFile loads and unmarshals a single config file. -// Use forceV2 if the config must in the v2 format. -func loadConfigFile(path string, forceV2 bool) (*parsedConfig, error) { +func loadConfigFile(path string) (*parsedConfig, error) { logrus.Debugf("Loading registries configuration %q", path) // tomlConfig allows us to unmarshal either V1 or V2 simultaneously. type tomlConfig struct { V2RegistriesConf - V1RegistriesConf // for backwards compatibility with sysregistries v1 + V1RegistriesConf // to detect no-longer-supported use of the v1 format } // Load the tomlConfig. Note that `DecodeFile` will overwrite set fields. @@ -968,21 +973,8 @@ func loadConfigFile(path string, forceV2 bool) (*parsedConfig, error) { } if combinedTOML.V1RegistriesConf.hasSetField() { - // Enforce the v2 format if requested. - if forceV2 { - return nil, &InvalidRegistries{s: "registry must be in v2 format but is in v1"} - } - - // Convert a v1 config into a v2 config. - if combinedTOML.V2RegistriesConf.hasSetField() { - return nil, &InvalidRegistries{s: fmt.Sprintf("mixing sysregistry v1/v2 is not supported: %#v", combinedTOML)} - } - converted, err := combinedTOML.V1RegistriesConf.ConvertToV2() - if err != nil { - return nil, err - } - combinedTOML.V1RegistriesConf = V1RegistriesConf{} - combinedTOML.V2RegistriesConf = *converted + // V1 format is no longer supported, produce hard error so callers know they must update the config. + return nil, &InvalidRegistries{s: "registry must be in v2 format but is in v1"} } res := parsedConfig{partialV2: combinedTOML.V2RegistriesConf} diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index f5b08094ad..7335da751f 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -461,38 +461,12 @@ func TestUnmarshalConfig(t *testing.T) { assert.Equal(t, 4, len(registries)) } -func TestV1BackwardsCompatibility(t *testing.T) { - sys := &types.SystemContext{ - SystemRegistriesConfPath: "testdata/v1-compatibility.conf", - SystemRegistriesConfDirPath: "testdata/this-does-not-exist", - } - - registries, err := GetRegistries(sys) - assert.Nil(t, err) - assert.Equal(t, 4, len(registries)) - - unqRegs, err := UnqualifiedSearchRegistries(sys) - assert.Nil(t, err) - assert.Equal(t, []string{"registry-a.com", "registry-c.com", "registry-d.com"}, unqRegs) - - // check if merging works - reg, err := FindRegistry(sys, "registry-b.com/bar/foo/barfoo:latest") - assert.Nil(t, err) - assert.NotNil(t, reg) - assert.True(t, reg.Insecure) - assert.True(t, reg.Blocked) - - for _, c := range []string{"testdata/v1-invalid-block.conf", "testdata/v1-invalid-insecure.conf", "testdata/v1-invalid-search.conf"} { - _, err := GetRegistries(&types.SystemContext{ - SystemRegistriesConfPath: c, - SystemRegistriesConfDirPath: "testdata/this-does-not-exist", - }) - assert.Error(t, err, c) - } -} - -func TestMixingV1andV2(t *testing.T) { +func TestV1SyntaxErrors(t *testing.T) { for _, c := range []string{ + "testdata/v1-compatibility.conf", + "testdata/v1-invalid-block.conf", + "testdata/v1-invalid-insecure.conf", + "testdata/v1-invalid-search.conf", "testdata/mixing-v1-v2.conf", "testdata/mixing-v1-v2-empty.conf", } { @@ -500,7 +474,7 @@ func TestMixingV1andV2(t *testing.T) { SystemRegistriesConfPath: c, SystemRegistriesConfDirPath: "testdata/this-does-not-exist", }) - assert.ErrorContains(t, err, "mixing sysregistry v1/v2 is not supported", c) + assert.ErrorContains(t, err, "registry must be in v2 format but is in v1", c) } } From c34d961a437614c892ec5d2941a3aebfa21bdcff Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Wed, 1 Apr 2026 21:33:33 +0200 Subject: [PATCH 02/10] storage: add two custom path options to pkg/configfile These should mirror the current registries.conf behavior for SystemRegistriesConfPath and SystemRegistriesConfDirPath so I can port this over to the new package. CustomConfigFilePath sets the single config path to the main file which we parse first, we must error if that path does not exists. Also if set we still must read normal drop ins from the default locations to keep the current registries.conf behavior. CustomConfigFileDropInDirectory sets the directory from which we read the drop in files instead of the default locations. If that path does not exists no drop in will be parsed. In addition these options have higher priority then the environment variables as they are often used for cli options which should be more important then the env. Signed-off-by: Paul Holzinger --- storage/pkg/configfile/parse.go | 59 +++++++- storage/pkg/configfile/parse_test.go | 200 +++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 7 deletions(-) diff --git a/storage/pkg/configfile/parse.go b/storage/pkg/configfile/parse.go index b80ed3fbf5..983daf46cb 100644 --- a/storage/pkg/configfile/parse.go +++ b/storage/pkg/configfile/parse.go @@ -57,6 +57,25 @@ type File struct { // NOTE: This does NOT affect paths starting by $HOME or environment variables paths. RootForImplicitAbsolutePaths string + // CustomConfigFilePath is the path to a specific file that will be parsed as main file instead + // of the default location files. Unlike the regular parsing logic if set this file must exists + // or ErrNotExist will be returned. Note when just using this option without also + // CustomConfigFileDropInDirectory it means the regular drop in directories are still searched + // assuming DoNotLoadDropInFiles is not set. + // This has higher priority over the EnvironmentName variable, so if set the env is ignored. + // RootForImplicitAbsolutePaths will not be used for this path. + // Optional. + CustomConfigFilePath string + + // CustomConfigFileDropInDirectory is the path to a specific drop in directory that will be searched + // instead of the default location. Note when just using this option without also + // CustomConfigFilePath it means the regular main file location is still being read assuming + // DoNotLoadMainFiles is not set. + // This has higher priority over the EnvironmentName + "_OVERRIDE" variable, so if set the env is ignored. + // RootForImplicitAbsolutePaths will not be used for this path. + // Optional. + CustomConfigFileDropInDirectory string + // DoNotLoadMainFiles should be set if only the Drop In files should be loaded. DoNotLoadMainFiles bool @@ -146,7 +165,21 @@ func Read(conf *File) iter.Seq2[*Item, error] { return ok } - if conf.EnvironmentName != "" { + if conf.CustomConfigFilePath != "" { + usedPaths = append(usedPaths, conf.CustomConfigFilePath) + f, err := os.Open(conf.CustomConfigFilePath) + if err != nil { + yield(nil, err) + return + } + + if !yieldAndClose(f) { + return + } + shouldLoadMainFile = false + // Only consider the env if no custom path was explicitly set. + // As this path often comes from cli options it is important it wins over the env value. + } else if conf.EnvironmentName != "" { if path := os.Getenv(conf.EnvironmentName); path != "" { usedPaths = append(usedPaths, path) f, err := os.Open(path) @@ -200,7 +233,16 @@ func Read(conf *File) iter.Seq2[*Item, error] { } if shouldLoadDropIns { - files, err := readDropIns(defaultConfig, overrideConfig, userConfig, conf.Extension, conf.UserId) + var ( + files []string + err error + ) + suffix := "." + conf.Extension + if conf.CustomConfigFileDropInDirectory != "" { + files, err = readDropInsFromPaths([]string{conf.CustomConfigFileDropInDirectory}, suffix) + } else { + files, err = readDropIns(defaultConfig, overrideConfig, userConfig, suffix, conf.UserId) + } if err != nil { // return error via iterator yield(nil, err) @@ -241,7 +283,7 @@ func Read(conf *File) iter.Seq2[*Item, error] { conf.Modules = resolvedModules } - if conf.EnvironmentName != "" && !conf.DoNotLoadDropInFiles { + if conf.EnvironmentName != "" && !conf.DoNotLoadDropInFiles && conf.CustomConfigFileDropInDirectory == "" { // The _OVERRIDE env must be appended after loading all files, even modules. if path := os.Getenv(conf.EnvironmentName + "_OVERRIDE"); path != "" { usedPaths = append(usedPaths, path) @@ -266,12 +308,9 @@ func Read(conf *File) iter.Seq2[*Item, error] { const dropInSuffix = ".d" -func readDropIns(defaultConfig, overrideConfig, userConfig, extension string, uid int) ([]string, error) { - dropInMap := make(map[string]string) +func readDropIns(defaultConfig, overrideConfig, userConfig, suffix string, uid int) ([]string, error) { paths := make([]string, 0, 7) - suffix := "." + extension - if defaultConfig != "" { paths = append(paths, getDropInPaths(defaultConfig, suffix, uid)...) } @@ -283,6 +322,12 @@ func readDropIns(defaultConfig, overrideConfig, userConfig, extension string, ui paths = append(paths, userConfig+dropInSuffix) } + return readDropInsFromPaths(paths, suffix) +} + +func readDropInsFromPaths(paths []string, suffix string) ([]string, error) { + dropInMap := make(map[string]string) + for _, path := range paths { entries, err := os.ReadDir(path) if err != nil { diff --git a/storage/pkg/configfile/parse_test.go b/storage/pkg/configfile/parse_test.go index 09b9446915..5c3d9f202d 100644 --- a/storage/pkg/configfile/parse_test.go +++ b/storage/pkg/configfile/parse_test.go @@ -558,6 +558,206 @@ func Test_Read(t *testing.T) { // CONTAINERS_CONF, then modules, then CONTAINERS_CONF_OVERRIDE want: []string{"env1", "mod", "env2"}, }, + { + name: "CustomConfigFilePath with drop in", + arg: File{ + Name: "containers", + Extension: "conf", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + file := filepath.Join(t.TempDir(), "somepath") + err := os.WriteFile(file, []byte("custom"), 0o600) + require.NoError(t, err) + + tc.arg.CustomConfigFilePath = file + }, + want: []string{"custom", "drop in"}, + }, + { + name: "CustomConfigFilePath ENOENT", + arg: File{ + Name: "containers", + Extension: "conf", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + file := filepath.Join(t.TempDir(), "IDoNotExist") + + tc.arg.CustomConfigFilePath = file + }, + wantErr: fs.ErrNotExist, + }, + { + name: "CustomConfigFilePath must win over env", + arg: File{ + Name: "containers", + Extension: "conf", + EnvironmentName: "CONTAINERS_CONF", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + file := filepath.Join(t.TempDir(), "file") + err := os.WriteFile(file, []byte("explicit path"), 0o600) + require.NoError(t, err) + tc.arg.CustomConfigFilePath = file + + file1 := filepath.Join(t.TempDir(), "path1") + err = os.WriteFile(file1, []byte("env"), 0o600) + require.NoError(t, err) + t.Setenv("CONTAINERS_CONF", file1) + }, + want: []string{"explicit path", "drop in"}, + }, + { + name: "CustomConfigFileDropInDirectory with main file", + arg: File{ + Name: "containers", + Extension: "conf", + }, + + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "file.conf"), []byte("custom"), 0o600) + require.NoError(t, err) + + // write a second file without .conf which should not be parsed + err = os.WriteFile(filepath.Join(dir, "somefile"), []byte("somefile"), 0o600) + require.NoError(t, err) + + tc.arg.CustomConfigFileDropInDirectory = dir + }, + want: []string{"main", "custom"}, + }, + { + name: "CustomConfigFileDropInDirectory does not error with ENOENT", + arg: File{ + Name: "containers", + Extension: "conf", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + dir := filepath.Join(t.TempDir(), "dirDoesNotExist") + tc.arg.CustomConfigFileDropInDirectory = dir + }, + want: []string{"main"}, + }, + { + name: "CustomConfigFileDropInDirectory must win over env", + arg: File{ + Name: "containers", + Extension: "conf", + EnvironmentName: "CONTAINERS_CONF", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "file.conf"), []byte("explicit dir"), 0o600) + require.NoError(t, err) + + tc.arg.CustomConfigFileDropInDirectory = dir + + file2 := filepath.Join(t.TempDir(), "path1") + err = os.WriteFile(file2, []byte("env"), 0o600) + require.NoError(t, err) + t.Setenv("CONTAINERS_CONF_OVERRIDE", file2) + }, + want: []string{"main", "explicit dir"}, + }, + { + name: "CustomConfigFilePath and CustomConfigFileDropInDirectory", + arg: File{ + Name: "containers", + Extension: "conf", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + file := filepath.Join(t.TempDir(), "file") + err := os.WriteFile(file, []byte("custom main"), 0o600) + require.NoError(t, err) + tc.arg.CustomConfigFilePath = file + + dir := t.TempDir() + err = os.WriteFile(filepath.Join(dir, "file.conf"), []byte("custom dir"), 0o600) + require.NoError(t, err) + + tc.arg.CustomConfigFileDropInDirectory = dir + }, + want: []string{"custom main", "custom dir"}, + }, + { + name: "CustomConfigFilePath and CustomConfigFileDropInDirectory must win over envs", + arg: File{ + Name: "containers", + Extension: "conf", + EnvironmentName: "CONTAINERS_CONF", + }, + files: testfiles{ + usr: map[string]string{ + "containers.conf": "main", + "containers.conf.d/10-myconf.conf": "drop in", + }, + }, + setup: func(t *testing.T, tc *testcase) { + file := filepath.Join(t.TempDir(), "file") + err := os.WriteFile(file, []byte("explicit path"), 0o600) + require.NoError(t, err) + tc.arg.CustomConfigFilePath = file + + file1 := filepath.Join(t.TempDir(), "path1") + err = os.WriteFile(file1, []byte("main env"), 0o600) + require.NoError(t, err) + t.Setenv("CONTAINERS_CONF", file1) + + dir := t.TempDir() + err = os.WriteFile(filepath.Join(dir, "file.conf"), []byte("explicit dir"), 0o600) + require.NoError(t, err) + + tc.arg.CustomConfigFileDropInDirectory = dir + + file2 := filepath.Join(t.TempDir(), "path1") + err = os.WriteFile(file2, []byte("override env"), 0o600) + require.NoError(t, err) + t.Setenv("CONTAINERS_CONF_OVERRIDE", file2) + }, + want: []string{"explicit path", "explicit dir"}, + }, } for _, tt := range tests { From 046e21b98a13e0f9ac115e08f685e46e3c5bb131 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Mon, 20 Apr 2026 14:00:11 +0200 Subject: [PATCH 03/10] storage: add GetSearchPaths() to pkg/configfile We need an API to get a list of all search paths to display it as part of error messages for registries.conf. This will also be needed to get the containers.conf module directories for the shell completion logic. Signed-off-by: Paul Holzinger --- storage/pkg/configfile/parse.go | 259 ++++++++++++++++----------- storage/pkg/configfile/parse_test.go | 58 +++++- 2 files changed, 209 insertions(+), 108 deletions(-) diff --git a/storage/pkg/configfile/parse.go b/storage/pkg/configfile/parse.go index 983daf46cb..ad09f98fee 100644 --- a/storage/pkg/configfile/parse.go +++ b/storage/pkg/configfile/parse.go @@ -109,19 +109,40 @@ type Item struct { Name string } -func getConfName(name, extension string, noExtension bool) string { - if noExtension { - return name +type SearchPaths struct { + // MainFiles are the main config file paths, ordered from highest priority to lower ones. + // For example: $HOME/..., then /etc/..., then /usr/... + // Can be empty if there are no main files for the given config. + MainFiles []string + // DropInDirectories is the list of drop in directories read by this config file, again + // ordered from highest priority to lower ones. + // Can be empty if there are no drop in directories for the given config. + DropInDirectories []string + // ModuleDirectories is the list of module directories checked by this config file, again + // ordered from highest priority to lower ones. + // Will be empty if no modules were request for the given conf. + ModuleDirectories []string + // The file path from conf.EnvironmentName + "_OVERRIDE" env if it must be parsed for the given config. + // Can be empty. + ExtraOverrideFile string +} + +func (f *File) getConfName() string { + if f.DoNotUseExtensionForConfigName { + return f.Name } - return name + "." + extension + return f.Name + "." + f.Extension } -// Read parses all config files with the specified options and returns an iterator which returns all files as Item in the right order. -// If an error is returned by the iterator then this must be treated as fatal error and must fail the config file parsing. -// Expected ENOENT errors are already ignored in this function and must not be handled again by callers. -// The given File options must not be nil and populated with valid options. -func Read(conf *File) iter.Seq2[*Item, error] { - configFileName := getConfName(conf.Name, conf.Extension, conf.DoNotUseExtensionForConfigName) +// GetSearchPaths returns the list of files which will be tried to be parsed. +// See the doc of [SearchPaths] for more information. +func GetSearchPaths(conf *File) (SearchPaths, error) { + paths, _, err := getSearchPaths(conf) + return paths, err +} + +func getSearchPaths(conf *File) (SearchPaths, bool, error) { + configFileName := conf.getConfName() // Note this can be empty which is a valid case and should be simply ignored then. defaultConfig := systemConfigPath @@ -141,13 +162,95 @@ func Read(conf *File) iter.Seq2[*Item, error] { } } + // userConfig can be empty as well + userConfig, err := UserConfigPath() + if err != nil { + return SearchPaths{}, false, err + } + if userConfig != "" { + userConfig = filepath.Join(userConfig, configFileName) + } + + // main files + ignoreENOENT := true + shouldLoadDropIns := true + var mainFiles []string + if !conf.DoNotLoadMainFiles { + if conf.CustomConfigFilePath != "" { + mainFiles = append(mainFiles, conf.CustomConfigFilePath) + ignoreENOENT = false + // Only consider the env if no custom path was explicitly set. + // As this path often comes from cli options it is important it wins over the env value. + } else if path := os.Getenv(conf.EnvironmentName); path != "" && conf.EnvironmentName != "" { + mainFiles = append(mainFiles, path) + ignoreENOENT = false + // Also when the env is set skip the loading of drop in files, modules and _OVERRIDE env are still read though. + shouldLoadDropIns = false + } else { + // default search paths + if userConfig != "" { + mainFiles = append(mainFiles, userConfig) + } + if overrideConfig != "" { + mainFiles = append(mainFiles, overrideConfig) + } + if defaultConfig != "" { + mainFiles = append(mainFiles, defaultConfig) + } + } + } + + // drop in dirs + var dropInDirs []string + var extraOverrideFilePath string + if !conf.DoNotLoadDropInFiles { + if shouldLoadDropIns { + if conf.CustomConfigFileDropInDirectory != "" { + dropInDirs = append(dropInDirs, conf.CustomConfigFileDropInDirectory) + } else { + // default search paths + dropInDirs = getDropInPaths(defaultConfig, overrideConfig, userConfig, "."+conf.Extension, conf.UserId) + } + } + + if conf.EnvironmentName != "" && conf.CustomConfigFileDropInDirectory == "" { + if path := os.Getenv(conf.EnvironmentName + "_OVERRIDE"); path != "" { + extraOverrideFilePath = path + } + } + } + + // modules + var modDirs []string + if len(conf.Modules) > 0 { + modDirs = moduleDirectories(defaultConfig, overrideConfig, userConfig) + } + + return SearchPaths{ + MainFiles: mainFiles, + DropInDirectories: dropInDirs, + ModuleDirectories: modDirs, + ExtraOverrideFile: extraOverrideFilePath, + }, + ignoreENOENT, + nil +} + +// Read parses all config files with the specified options and returns an iterator which returns all files as Item in the right order. +// If an error is returned by the iterator then this must be treated as fatal error and must fail the config file parsing. +// Expected ENOENT errors are already ignored in this function and must not be handled again by callers. +// The given File options must not be nil and populated with valid options. +func Read(conf *File) iter.Seq2[*Item, error] { return func(yield func(*Item, error) bool) { + paths, ignoreMainENOENT, err := getSearchPaths(conf) + if err != nil { + yield(nil, err) + return + } + usedPaths := make([]string, 0, 8) foundAny := false - shouldLoadMainFile := !conf.DoNotLoadMainFiles - shouldLoadDropIns := !conf.DoNotLoadDropInFiles - yieldAndClose := func(f *os.File) bool { foundAny = true ok := yield(&Item{ @@ -165,10 +268,17 @@ func Read(conf *File) iter.Seq2[*Item, error] { return ok } - if conf.CustomConfigFilePath != "" { - usedPaths = append(usedPaths, conf.CustomConfigFilePath) - f, err := os.Open(conf.CustomConfigFilePath) + for _, path := range paths.MainFiles { + if path == "" { + continue + } + usedPaths = append(usedPaths, path) + f, err := os.Open(path) if err != nil { + // only ignore ErrNotExist when needed, all other errors get return to the caller via yield + if ignoreMainENOENT && errors.Is(err, fs.ErrNotExist) { + continue + } yield(nil, err) return } @@ -176,73 +286,13 @@ func Read(conf *File) iter.Seq2[*Item, error] { if !yieldAndClose(f) { return } - shouldLoadMainFile = false - // Only consider the env if no custom path was explicitly set. - // As this path often comes from cli options it is important it wins over the env value. - } else if conf.EnvironmentName != "" { - if path := os.Getenv(conf.EnvironmentName); path != "" { - usedPaths = append(usedPaths, path) - f, err := os.Open(path) - // Do not ignore ErrNotExist here, we want to hard error if users set a wrong path here. - if err != nil { - yield(nil, err) - return - } - if !yieldAndClose(f) { - return - } - // Also when the env is set skip the loading of the main and drop in files, modules and _OVERRIDE env are still read though. - shouldLoadMainFile = false - shouldLoadDropIns = false - } - } - - // userConfig can be empty as well - userConfig, err := UserConfigPath() - if err != nil { - // return error via iterator - yield(nil, err) - return - } - if userConfig != "" { - userConfig = filepath.Join(userConfig, configFileName) - } - - if shouldLoadMainFile { - for _, path := range []string{userConfig, overrideConfig, defaultConfig} { - if path == "" { - continue - } - usedPaths = append(usedPaths, path) - f, err := os.Open(path) - // only ignore ErrNotExist, all other errors get return to the caller via yield - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - continue - } - yield(nil, err) - return - } - - if !yieldAndClose(f) { - return - } - // we only read the first file - break - } + // we only read the first found file + break } - if shouldLoadDropIns { - var ( - files []string - err error - ) + if len(paths.DropInDirectories) > 0 { suffix := "." + conf.Extension - if conf.CustomConfigFileDropInDirectory != "" { - files, err = readDropInsFromPaths([]string{conf.CustomConfigFileDropInDirectory}, suffix) - } else { - files, err = readDropIns(defaultConfig, overrideConfig, userConfig, suffix, conf.UserId) - } + files, err := readDropInsFromPaths(paths.DropInDirectories, suffix) if err != nil { // return error via iterator yield(nil, err) @@ -251,7 +301,7 @@ func Read(conf *File) iter.Seq2[*Item, error] { for _, file := range files { usedPaths = append(usedPaths, file) f, err := os.Open(file) - // only ignore ErrNotExist, all other errors get return to the caller via yield + // always ignore ErrNotExist, all other errors get return to the caller via yield if err != nil { if errors.Is(err, fs.ErrNotExist) { continue @@ -267,10 +317,9 @@ func Read(conf *File) iter.Seq2[*Item, error] { } if len(conf.Modules) > 0 { - dirs := moduleDirectories(defaultConfig, overrideConfig, userConfig) resolvedModules := make([]string, 0, len(conf.Modules)) for _, module := range conf.Modules { - f, err := resolveModule(module, dirs, &usedPaths) + f, err := resolveModule(module, paths.ModuleDirectories, &usedPaths) if err != nil { yield(nil, fmt.Errorf("could not resolve module: %w", err)) return @@ -283,24 +332,22 @@ func Read(conf *File) iter.Seq2[*Item, error] { conf.Modules = resolvedModules } - if conf.EnvironmentName != "" && !conf.DoNotLoadDropInFiles && conf.CustomConfigFileDropInDirectory == "" { + if paths.ExtraOverrideFile != "" { // The _OVERRIDE env must be appended after loading all files, even modules. - if path := os.Getenv(conf.EnvironmentName + "_OVERRIDE"); path != "" { - usedPaths = append(usedPaths, path) - f, err := os.Open(path) - // Do not ignore ErrNotExist here, we want to hard error if users set a wrong path here. - if err != nil { - yield(nil, err) - return - } - if !yieldAndClose(f) { - return - } + usedPaths = append(usedPaths, paths.ExtraOverrideFile) + f, err := os.Open(paths.ExtraOverrideFile) + // Do not ignore ErrNotExist here, we want to hard error if users set a wrong path here. + if err != nil { + yield(nil, err) + return + } + if !yieldAndClose(f) { + return } } if conf.ErrorIfNotFound && !foundAny { - yield(nil, fmt.Errorf("%w: no %s file found; searched paths: %q", ErrConfigFileNotFound, configFileName, usedPaths)) + yield(nil, fmt.Errorf("%w: no %s file found; searched paths: %q", ErrConfigFileNotFound, conf.getConfName(), usedPaths)) return } } @@ -308,27 +355,27 @@ func Read(conf *File) iter.Seq2[*Item, error] { const dropInSuffix = ".d" -func readDropIns(defaultConfig, overrideConfig, userConfig, suffix string, uid int) ([]string, error) { +func getDropInPaths(defaultConfig, overrideConfig, userConfig, suffix string, uid int) []string { paths := make([]string, 0, 7) - if defaultConfig != "" { - paths = append(paths, getDropInPaths(defaultConfig, suffix, uid)...) - } - if overrideConfig != "" { - paths = append(paths, getDropInPaths(overrideConfig, suffix, uid)...) - } if userConfig != "" { // the $HOME config only has one .d path not the rootful/rootless ones. paths = append(paths, userConfig+dropInSuffix) } + if overrideConfig != "" { + paths = append(paths, getDropInPathsUnderMain(overrideConfig, suffix, uid)...) + } + if defaultConfig != "" { + paths = append(paths, getDropInPathsUnderMain(defaultConfig, suffix, uid)...) + } - return readDropInsFromPaths(paths, suffix) + return paths } func readDropInsFromPaths(paths []string, suffix string) ([]string, error) { dropInMap := make(map[string]string) - for _, path := range paths { + for _, path := range slices.Backward(paths) { entries, err := os.ReadDir(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -351,7 +398,7 @@ func readDropInsFromPaths(paths []string, suffix string) ([]string, error) { return files, nil } -func getDropInPaths(mainPath, suffix string, uid int) []string { +func getDropInPathsUnderMain(mainPath, suffix string, uid int) []string { paths := make([]string, 0, 3) paths = append(paths, mainPath+dropInSuffix) diff --git a/storage/pkg/configfile/parse_test.go b/storage/pkg/configfile/parse_test.go index 5c3d9f202d..3c94751da1 100644 --- a/storage/pkg/configfile/parse_test.go +++ b/storage/pkg/configfile/parse_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_getDropInPaths(t *testing.T) { +func Test_getDropInPathsUnderMain(t *testing.T) { tests := []struct { name string // Arguments for this function @@ -89,7 +89,7 @@ func Test_getDropInPaths(t *testing.T) { t.Parallel() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getDropInPaths(tt.mainPath, tt.suffix, tt.uid) + got := getDropInPathsUnderMain(tt.mainPath, tt.suffix, tt.uid) assert.Equal(t, tt.want, got) }) } @@ -941,3 +941,57 @@ func Test_ParseTOML(t *testing.T) { }) } } + +func TestGetSearchPaths(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/home") + tests := []struct { + name string + conf File + want SearchPaths + }{ + { + name: "basic containers.conf", + conf: File{ + Name: "containers", + Extension: "conf", + }, + want: SearchPaths{ + MainFiles: []string{ + "/home/containers/containers.conf", + adminOverrideConfigPath + "/containers.conf", + systemConfigPath + "/containers.conf", + }, + DropInDirectories: []string{ + "/home/containers/containers.conf.d", + adminOverrideConfigPath + "/containers.conf.d", + adminOverrideConfigPath + "/containers.rootful.conf.d", + systemConfigPath + "/containers.conf.d", + systemConfigPath + "/containers.rootful.conf.d", + }, + }, + }, + { + name: "basic policy.json", + conf: File{ + Name: "policy", + Extension: "json", + DoNotLoadDropInFiles: true, + }, + want: SearchPaths{ + MainFiles: []string{ + "/home/containers/policy.json", + adminOverrideConfigPath + "/policy.json", + systemConfigPath + "/policy.json", + }, + }, + }, + // TODO: add more test cases + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetSearchPaths(&tt.conf) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} From 5ce9220182656937b8afecc5922ab83edafce9f6 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Tue, 14 Apr 2026 15:16:18 +0200 Subject: [PATCH 04/10] image: port registries.conf to pkg/configfile Rewrite registries.conf parsing to use our new configfile package. https://github.com/containers/podman/blob/34a4633d5fd4a502cef289b4f3f449535a7e1067/contrib/design-docs/config-file-parsing.md Signed-off-by: Paul Holzinger --- image/pkg/sysregistriesv2/paths_common.go | 11 - image/pkg/sysregistriesv2/paths_freebsd.go | 11 - image/pkg/sysregistriesv2/shortnames_test.go | 4 +- .../sysregistriesv2/system_registries_v2.go | 252 ++++++------------ .../system_registries_v2_test.go | 94 +++---- 5 files changed, 125 insertions(+), 247 deletions(-) delete mode 100644 image/pkg/sysregistriesv2/paths_common.go delete mode 100644 image/pkg/sysregistriesv2/paths_freebsd.go diff --git a/image/pkg/sysregistriesv2/paths_common.go b/image/pkg/sysregistriesv2/paths_common.go deleted file mode 100644 index c9e8ac5cbd..0000000000 --- a/image/pkg/sysregistriesv2/paths_common.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !freebsd - -package sysregistriesv2 - -// builtinRegistriesConfPath is the path to the registry configuration file. -// DO NOT change this, instead see systemRegistriesConfPath above. -const builtinRegistriesConfPath = "/etc/containers/registries.conf" - -// builtinRegistriesConfDirPath is the path to the registry configuration directory. -// DO NOT change this, instead see systemRegistriesConfDirectoryPath above. -const builtinRegistriesConfDirPath = "/etc/containers/registries.conf.d" diff --git a/image/pkg/sysregistriesv2/paths_freebsd.go b/image/pkg/sysregistriesv2/paths_freebsd.go deleted file mode 100644 index 7dada4b779..0000000000 --- a/image/pkg/sysregistriesv2/paths_freebsd.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build freebsd - -package sysregistriesv2 - -// builtinRegistriesConfPath is the path to the registry configuration file. -// DO NOT change this, instead see systemRegistriesConfPath above. -const builtinRegistriesConfPath = "/usr/local/etc/containers/registries.conf" - -// builtinRegistriesConfDirPath is the path to the registry configuration directory. -// DO NOT change this, instead see systemRegistriesConfDirectoryPath above. -const builtinRegistriesConfDirPath = "/usr/local/etc/containers/registries.conf.d" diff --git a/image/pkg/sysregistriesv2/shortnames_test.go b/image/pkg/sysregistriesv2/shortnames_test.go index 781126b381..dee605ac2c 100644 --- a/image/pkg/sysregistriesv2/shortnames_test.go +++ b/image/pkg/sysregistriesv2/shortnames_test.go @@ -117,7 +117,7 @@ func TestResolveShortNameAlias(t *testing.T) { } InvalidateCache() - conf, err := tryUpdatingCache(sys, newConfigWrapper(sys)) + conf, err := tryUpdatingCache(newConfigWrapper(sys)) require.NoError(t, err) assert.Len(t, conf.aliasCache.namedAliases, 4) assert.Len(t, conf.partialV2.Aliases, 0) // This is an implementation detail, not an API guarantee. @@ -172,7 +172,7 @@ func TestAliasesWithDropInConfigs(t *testing.T) { } InvalidateCache() - conf, err := tryUpdatingCache(sys, newConfigWrapper(sys)) + conf, err := tryUpdatingCache(newConfigWrapper(sys)) require.NoError(t, err) assert.Len(t, conf.aliasCache.namedAliases, 8) assert.Len(t, conf.partialV2.Aliases, 0) // This is an implementation detail, not an API guarantee. diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index 72e7ede6e2..ec99540940 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -1,12 +1,8 @@ package sysregistriesv2 import ( - "errors" "fmt" - "io/fs" "maps" - "os" - "path/filepath" "reflect" "slices" "sort" @@ -17,23 +13,12 @@ import ( "github.com/sirupsen/logrus" "go.podman.io/image/v5/docker/reference" "go.podman.io/image/v5/types" + "go.podman.io/storage/pkg/configfile" "go.podman.io/storage/pkg/fileutils" - "go.podman.io/storage/pkg/homedir" "go.podman.io/storage/pkg/regexp" + "go.podman.io/storage/pkg/unshare" ) -// systemRegistriesConfPath is the path to the system-wide registry -// configuration file and is used to add/subtract potential registries for -// obtaining images. You can override this at build time with -// -ldflags '-X go.podman.io/image/v5/sysregistries.systemRegistriesConfPath=$your_path' -var systemRegistriesConfPath = builtinRegistriesConfPath - -// systemRegistriesConfDirPath is the path to the system-wide registry -// configuration directory and is used to add/subtract potential registries for -// obtaining images. You can override this at build time with -// -ldflags '-X go.podman.io/image/v5/sysregistries.systemRegistriesConfDirectoryPath=$your_path' -var systemRegistriesConfDirPath = builtinRegistriesConfDirPath - // AuthenticationFileHelper is a special key for credential helpers indicating // the usage of consulting containers-auth.json files instead of a credential // helper. @@ -73,12 +58,6 @@ type Endpoint struct { PullFromMirror string `toml:"pull-from-mirror,omitempty"` } -// userRegistriesFile is the path to the per user registry configuration file. -var userRegistriesFile = filepath.FromSlash(".config/containers/registries.conf") - -// userRegistriesDir is the path to the per user registry configuration file. -var userRegistriesDir = filepath.FromSlash(".config/containers/registries.conf.d") - // rewriteReference will substitute the provided reference `prefix` to the // endpoints `location` from the `ref` and creates a new named reference from it. // The function errors if the newly created reference is not parsable. @@ -536,95 +515,90 @@ func (config *V2RegistriesConf) postProcessRegistries() error { } // ConfigPath returns the path to the system-wide registry configuration file. +// This may return an empty string if it fails to resolve a home directory. // // Deprecated: This API implies configuration is read from files, and that there is only one. // Please use ConfigurationSourceDescription to obtain a string usable for error messages. func ConfigPath(ctx *types.SystemContext) string { - return newConfigWrapper(ctx).configPath + configWrapper := newConfigWrapper(ctx) + paths, err := configfile.GetSearchPaths(configWrapper.toConfigFileOptions()) + if err == nil && len(paths.MainFiles) > 0 { + for _, file := range paths.MainFiles { + if fileutils.Exists(file) == nil { + return file + } + } + // nothing exists return first file + return paths.MainFiles[0] + } + return "" } // ConfigDirPath returns the path to the directory for drop-in -// registry configuration files. +// registry configuration files. This may return an empty string if it fails to resolve a home directory. // // Deprecated: This API implies configuration is read from directories, and that there is only one. // Please use ConfigurationSourceDescription to obtain a string usable for error messages. func ConfigDirPath(ctx *types.SystemContext) string { configWrapper := newConfigWrapper(ctx) - if configWrapper.userConfigDirPath != "" { - return configWrapper.userConfigDirPath + paths, err := configfile.GetSearchPaths(configWrapper.toConfigFileOptions()) + if err == nil && len(paths.DropInDirectories) > 0 { + return paths.DropInDirectories[0] } - return configWrapper.configDirPath + return "" } // configWrapper is used to store the paths from ConfigPath and ConfigDirPath // and acts as a key to the internal cache. +// +// Note some env vars like CONTAINERS_REGISTRIES_CONF or $HOME will effect the +// parsing behavior but are not used for the cache key here, if these env change +// at runtime then the cache will result in correct results. This is a known trade +// off as regular callers are not expected to modify the envs at runtime. +// If they do so they should call [InvalidateCache]. type configWrapper struct { - // path to the registries.conf file - configPath string - // path to system-wide registries.conf.d directory, or "" if not used - configDirPath string - // path to user specified registries.conf.d directory, or "" if not used - userConfigDirPath string -} + // system context override to specific path + systemRegistriesConfPath string + // system context override to specific drop in directory + systemRegistriesConfDirPath string -// newConfigWrapper returns a configWrapper for the specified SystemContext. -func newConfigWrapper(ctx *types.SystemContext) configWrapper { - return newConfigWrapperWithHomeDir(ctx, homedir.Get()) + // system context override to root directory + rootForImplicitAbsolutePaths string } -// newConfigWrapperWithHomeDir is an internal implementation detail of newConfigWrapper, -// it exists only to allow testing it with an artificial home directory. -func newConfigWrapperWithHomeDir(ctx *types.SystemContext, homeDir string) configWrapper { - var wrapper configWrapper - userRegistriesFilePath := filepath.Join(homeDir, userRegistriesFile) - userRegistriesDirPath := filepath.Join(homeDir, userRegistriesDir) - - // decide configPath using per-user path or system file - if ctx != nil && ctx.SystemRegistriesConfPath != "" { - wrapper.configPath = ctx.SystemRegistriesConfPath - } else if err := fileutils.Exists(userRegistriesFilePath); err == nil { - // per-user registries.conf exists, not reading system dir - // return config dirs from ctx or per-user one - wrapper.configPath = userRegistriesFilePath - if ctx != nil && ctx.SystemRegistriesConfDirPath != "" { - wrapper.configDirPath = ctx.SystemRegistriesConfDirPath - } else { - wrapper.userConfigDirPath = userRegistriesDirPath - } - - return wrapper - } else if ctx != nil && ctx.RootForImplicitAbsolutePaths != "" { - wrapper.configPath = filepath.Join(ctx.RootForImplicitAbsolutePaths, systemRegistriesConfPath) - } else { - wrapper.configPath = systemRegistriesConfPath +func (c *configWrapper) toConfigFileOptions() *configfile.File { + return &configfile.File{ + Name: "registries", + Extension: "conf", + EnvironmentName: "CONTAINERS_REGISTRIES_CONF", + RootForImplicitAbsolutePaths: c.rootForImplicitAbsolutePaths, + CustomConfigFilePath: c.systemRegistriesConfPath, + CustomConfigFileDropInDirectory: c.systemRegistriesConfDirPath, + UserId: unshare.GetRootlessUID(), } +} - // potentially use both system and per-user dirs if not using per-user config file - if ctx != nil && ctx.SystemRegistriesConfDirPath != "" { - // dir explicitly chosen: use only that one - wrapper.configDirPath = ctx.SystemRegistriesConfDirPath - } else if ctx != nil && ctx.RootForImplicitAbsolutePaths != "" { - wrapper.configDirPath = filepath.Join(ctx.RootForImplicitAbsolutePaths, systemRegistriesConfDirPath) - wrapper.userConfigDirPath = userRegistriesDirPath - } else { - wrapper.configDirPath = systemRegistriesConfDirPath - wrapper.userConfigDirPath = userRegistriesDirPath +// newConfigWrapper returns a configWrapper for the specified SystemContext. +func newConfigWrapper(ctx *types.SystemContext) configWrapper { + var wrapper configWrapper + if ctx != nil { + wrapper.systemRegistriesConfPath = ctx.SystemRegistriesConfPath + wrapper.systemRegistriesConfDirPath = ctx.SystemRegistriesConfDirPath + wrapper.rootForImplicitAbsolutePaths = ctx.RootForImplicitAbsolutePaths } return wrapper } -// ConfigurationSourceDescription returns a string containers paths of registries.conf and registries.conf.d +// ConfigurationSourceDescription returns a string of one or more paths to registries.conf and registries.conf.d. +// This may return an empty string in case it fails to resolve all paths. func ConfigurationSourceDescription(ctx *types.SystemContext) string { - wrapper := newConfigWrapper(ctx) - configSources := []string{wrapper.configPath} - if wrapper.configDirPath != "" { - configSources = append(configSources, wrapper.configDirPath) - } - if wrapper.userConfigDirPath != "" { - configSources = append(configSources, wrapper.userConfigDirPath) + configWrapper := newConfigWrapper(ctx) + paths, err := configfile.GetSearchPaths(configWrapper.toConfigFileOptions()) + if err != nil { + return "" } - return strings.Join(configSources, ", ") + return fmt.Sprintf("%q", slices.Concat(paths.MainFiles, paths.DropInDirectories)) } // configMutex is used to synchronize concurrent accesses to configCache. @@ -654,58 +628,7 @@ func getConfig(ctx *types.SystemContext) (*parsedConfig, error) { } configMutex.Unlock() - return tryUpdatingCache(ctx, wrapper) -} - -// dropInConfigs returns a slice of drop-in-configs from the registries.conf.d -// directory. -func dropInConfigs(wrapper configWrapper) ([]string, error) { - var ( - configs []string - dirPaths []string - ) - if wrapper.configDirPath != "" { - dirPaths = append(dirPaths, wrapper.configDirPath) - } - if wrapper.userConfigDirPath != "" { - dirPaths = append(dirPaths, wrapper.userConfigDirPath) - } - for _, dirPath := range dirPaths { - 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 == nil: - // this should only happen when err != nil but let's be sure - return nil - 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") { - configs = append(configs, path) - } - return nil - } - }, - ) - - if err != nil && !os.IsNotExist(err) { - // Ignore IsNotExist errors: most systems won't have a registries.conf.d - // directory. - return nil, fmt.Errorf("reading registries.conf.d: %w", err) - } - } - - return configs, nil + return tryUpdatingCache(wrapper) } // TryUpdatingCache loads the configuration from the provided `SystemContext` @@ -714,7 +637,7 @@ func dropInConfigs(wrapper configWrapper) ([]string, error) { // It returns the resulting configuration; this is DEPRECATED and may not correctly // reflect any future data handled by this package. func TryUpdatingCache(ctx *types.SystemContext) (*V2RegistriesConf, error) { - config, err := tryUpdatingCache(ctx, newConfigWrapper(ctx)) + config, err := tryUpdatingCache(newConfigWrapper(ctx)) if err != nil { return nil, err } @@ -723,46 +646,31 @@ func TryUpdatingCache(ctx *types.SystemContext) (*V2RegistriesConf, error) { // tryUpdatingCache implements TryUpdatingCache with an additional configWrapper // argument to avoid redundantly calculating the config paths. -func tryUpdatingCache(ctx *types.SystemContext, wrapper configWrapper) (*parsedConfig, error) { +func tryUpdatingCache(wrapper configWrapper) (*parsedConfig, error) { configMutex.Lock() defer configMutex.Unlock() - // load the config - config, err := loadConfigFile(wrapper.configPath) - if err != nil { - // Continue with an empty []Registry if we use the default config, which - // implies that the config path of the SystemContext isn't set. - // - // Note: if ctx.SystemRegistriesConfPath points to the default config, - // we will still return an error. - if os.IsNotExist(err) && (ctx == nil || ctx.SystemRegistriesConfPath == "") { - config = &parsedConfig{} - config.partialV2 = V2RegistriesConf{Registries: []Registry{}} - config.aliasCache, err = newShortNameAliasCache("", &shortNameAliasConf{}) - if err != nil { - return nil, err // Should never happen - } - } else { - return nil, fmt.Errorf("loading registries configuration %q: %w", wrapper.configPath, err) - } + config := &parsedConfig{ + partialV2: V2RegistriesConf{Registries: []Registry{}}, } - // Load the configs from the conf directory path. - dinConfigs, err := dropInConfigs(wrapper) + var err error + config.aliasCache, err = newShortNameAliasCache("", &shortNameAliasConf{}) if err != nil { - return nil, err + return nil, err // Should never happen } - for _, path := range dinConfigs { - dropIn, err := loadConfigFile(path) + + for item, err := range configfile.Read(wrapper.toConfigFileOptions()) { if err != nil { - if errors.Is(err, fs.ErrNotExist) { - // file must have been removed between the directory listing - // and the open call, ignore that as it is a expected race - continue - } - return nil, fmt.Errorf("loading drop-in registries configuration %q: %w", path, err) + return nil, err } - config.updateWithConfigurationFrom(dropIn) + logrus.Debugf("Loading registries configuration %q", item.Name) + + parsed, err := loadConfigFile(item) + if err != nil { + return nil, fmt.Errorf("loading registries configuration %q: %w", item.Name, err) + } + config.updateWithConfigurationFrom(parsed) } if config.shortNameMode == types.ShortNameModeInvalid { @@ -953,9 +861,7 @@ func findRegistryWithParsedConfig(config *parsedConfig, ref string) (*Registry, } // loadConfigFile loads and unmarshals a single config file. -func loadConfigFile(path string) (*parsedConfig, error) { - logrus.Debugf("Loading registries configuration %q", path) - +func loadConfigFile(item *configfile.Item) (*parsedConfig, error) { // tomlConfig allows us to unmarshal either V1 or V2 simultaneously. type tomlConfig struct { V2RegistriesConf @@ -964,12 +870,12 @@ func loadConfigFile(path string) (*parsedConfig, error) { // Load the tomlConfig. Note that `DecodeFile` will overwrite set fields. var combinedTOML tomlConfig - meta, err := toml.DecodeFile(path, &combinedTOML) + meta, err := toml.NewDecoder(item.Reader).Decode(&combinedTOML) if err != nil { return nil, err } if keys := meta.Undecoded(); len(keys) > 0 { - logrus.Debugf("Failed to decode keys %q from %q", keys, path) + logrus.Debugf("Failed to decode keys %q from %q", keys, item.Name) } if combinedTOML.V1RegistriesConf.hasSetField() { @@ -984,7 +890,7 @@ func loadConfigFile(path string) (*parsedConfig, error) { return nil, err } - res.unqualifiedSearchRegistriesOrigin = path + res.unqualifiedSearchRegistriesOrigin = item.Name if len(res.partialV2.ShortNameMode) > 0 { mode, err := parseShortNameMode(res.partialV2.ShortNameMode) @@ -1008,7 +914,7 @@ func loadConfigFile(path string) (*parsedConfig, error) { } // Parse and validate short-name aliases. - cache, err := newShortNameAliasCache(path, &res.partialV2.shortNameAliasConf) + cache, err := newShortNameAliasCache(item.Name, &res.partialV2.shortNameAliasConf) if err != nil { return nil, fmt.Errorf("validating short-name aliases: %w", err) } diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index 7335da751f..5d451b9109 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -238,66 +238,60 @@ func TestRefMatchingPrefix(t *testing.T) { } func TestNewConfigWrapper(t *testing.T) { - const nondefaultPath = "/this/is/not/the/default/registries.conf" - const variableReference = "$HOME" - const rootPrefix = "/root/prefix" - tempHome := t.TempDir() - userRegistriesFile := filepath.FromSlash(".config/containers/registries.conf") - userRegistriesFilePath := filepath.Join(tempHome, userRegistriesFile) - - for _, c := range []struct { - sys *types.SystemContext - userfilePresent bool - expected string + for _, tt := range []struct { + name string + sys *types.SystemContext + want configWrapper }{ - // The common case - {nil, false, systemRegistriesConfPath}, - // There is a context, but it does not override the path. - {&types.SystemContext{}, false, systemRegistriesConfPath}, - // Path overridden - {&types.SystemContext{SystemRegistriesConfPath: nondefaultPath}, false, nondefaultPath}, - // Root overridden { - &types.SystemContext{RootForImplicitAbsolutePaths: rootPrefix}, - false, - filepath.Join(rootPrefix, systemRegistriesConfPath), + name: "nil context", + sys: nil, + want: configWrapper{}, + }, + { + name: "empty context", + sys: &types.SystemContext{}, + want: configWrapper{}, }, - // Root and path overrides present simultaneously, { - &types.SystemContext{ - RootForImplicitAbsolutePaths: rootPrefix, - SystemRegistriesConfPath: nondefaultPath, + name: "RootForImplicitAbsolutePaths", + sys: &types.SystemContext{ + RootForImplicitAbsolutePaths: "root", }, - false, - nondefaultPath, + want: configWrapper{rootForImplicitAbsolutePaths: "root"}, }, - // User registries file overridden - {&types.SystemContext{}, true, userRegistriesFilePath}, - // Context and user User registries file preset simultaneously - {&types.SystemContext{SystemRegistriesConfPath: nondefaultPath}, true, nondefaultPath}, - // Root and user registries file overrides present simultaneously, { - &types.SystemContext{ - RootForImplicitAbsolutePaths: rootPrefix, - SystemRegistriesConfPath: nondefaultPath, + name: "SystemRegistriesConfPath", + sys: &types.SystemContext{ + SystemRegistriesConfPath: "somepath", + }, + want: configWrapper{systemRegistriesConfPath: "somepath"}, + }, + { + name: "SystemRegistriesConfDirPath", + sys: &types.SystemContext{ + SystemRegistriesConfDirPath: "somepath", + }, + want: configWrapper{systemRegistriesConfDirPath: "somepath"}, + }, + { + name: "all fields", + sys: &types.SystemContext{ + RootForImplicitAbsolutePaths: "a", + SystemRegistriesConfPath: "b", + SystemRegistriesConfDirPath: "c", + }, + want: configWrapper{ + rootForImplicitAbsolutePaths: "a", + systemRegistriesConfPath: "b", + systemRegistriesConfDirPath: "c", }, - true, - nondefaultPath, }, - // No environment expansion happens in the overridden paths - {&types.SystemContext{SystemRegistriesConfPath: variableReference}, false, variableReference}, } { - if c.userfilePresent { - err := os.MkdirAll(filepath.Dir(userRegistriesFilePath), os.ModePerm) - require.NoError(t, err) - f, err := os.Create(userRegistriesFilePath) - require.NoError(t, err) - f.Close() - } else { - os.Remove(userRegistriesFilePath) - } - path := newConfigWrapperWithHomeDir(c.sys, tempHome).configPath - assert.Equal(t, c.expected, path) + t.Run(tt.name, func(t *testing.T) { + got := newConfigWrapper(tt.sys) + assert.Equal(t, tt.want, got) + }) } } From 4df2391a7ec2f2b313b06b025c78bfffa84d5735 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Mon, 20 Apr 2026 15:55:55 +0200 Subject: [PATCH 05/10] common: libimage test use valid SystemRegistriesConfDirPath /dev/null is not a valid directory, the new reworked logic correctly fails wehn given a non directory path while the old just ignored it. As such make sure we pass an valid empty directory here. Signed-off-by: Paul Holzinger --- common/libimage/runtime_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/libimage/runtime_test.go b/common/libimage/runtime_test.go index 5c1d24106a..66ce029f6e 100644 --- a/common/libimage/runtime_test.go +++ b/common/libimage/runtime_test.go @@ -39,7 +39,7 @@ func testNewRuntime(t *testing.T, options ...testNewRuntimeOptions) *Runtime { // Make sure that the tests do not use the host's registries.conf. systemContext := &types.SystemContext{ SystemRegistriesConfPath: "testdata/registries.conf", - SystemRegistriesConfDirPath: "/dev/null", + SystemRegistriesConfDirPath: t.TempDir(), } if len(options) == 1 { From 8bd4696d0b3e007470010ebf629c26d53ec113c1 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Mon, 20 Apr 2026 17:58:51 +0200 Subject: [PATCH 06/10] update containers-registries.conf.5 docs Update the docs for the new registries.conf parsing behavior. Remove the old containers-registries.conf.d.5 man page and just link to the main one to dedup some content. The install-docs Makefile target should install this "link" just fine as is. Signed-off-by: Paul Holzinger --- image/docs/containers-registries.conf.5.md | 41 +++++++++++++++++++- image/docs/containers-registries.conf.d.5 | 1 + image/docs/containers-registries.conf.d.5.md | 34 ---------------- 3 files changed, 41 insertions(+), 35 deletions(-) create mode 100644 image/docs/containers-registries.conf.d.5 delete mode 100644 image/docs/containers-registries.conf.d.5.md diff --git a/image/docs/containers-registries.conf.5.md b/image/docs/containers-registries.conf.5.md index 1079706a4f..d9706d0ea7 100644 --- a/image/docs/containers-registries.conf.5.md +++ b/image/docs/containers-registries.conf.5.md @@ -9,7 +9,46 @@ containers-registries.conf - Syntax of System Registry Configuration File The CONTAINERS-REGISTRIES configuration file is a system-wide configuration file for container image registries. The file format is TOML. -Container engines will use the `$HOME/.config/containers/registries.conf` if it exists, otherwise they will use `/etc/containers/registries.conf` +By default, the configuration is read from `$XDG_CONFIG_HOME/containers/registries.conf` (or from `$HOME/.config/containers/registries.conf` if `$XDG_CONFIG_HOME` is unset), if it exists; otherwise from `/etc/containers/registries.conf`; otherwise from `/usr/share/containers/registries.conf`. Applications may allow using a different configuration path instead. + +If `CONTAINERS_REGISTRIES_CONF` is set, it specifies the configuration file to use, +unless overridden by application-specific configuration. If the environment variable +is set then the following drop-in directories will not be read. + +In addition to registries.conf, drop-in files using the same format from the following directories are also read: + - `$XDG_CONFIG_HOME/containers/registries.conf.d` (or from `$HOME/.config/containers/registries.conf.d` if `$XDG_CONFIG_HOME` is unset) + - `/etc/containers/registries.conf.d` + - `/etc/containers/registries.rootful.conf.d` (only when running as uid 0) + - `/etc/containers/registries.rootless.conf.d` (only when running as uid > 0) + - `/etc/containers/registries.rootless.conf.d/$UID` (only when running as uid > 0) + - `/usr/share/containers/registries.rootful.conf.d` (only when running as uid 0) + - `/usr/share/containers/registries.rootless.conf.d` (only when running as uid > 0) + - `/usr/share/containers/registries.rootless.conf.d/$UID` (only when running as uid > 0) + +The files must be using the `.conf` suffix, directories or files with other suffixes will be ignored. +All files from these paths will be first collected and then sorted in alpha-numerical order. +If the same filename is used twice then only the first match from the directory list above is +being used. Then the files will be parsed in the sorted order. + +For example consider these files: + +- `/usr/share/containers/registries.rootless.conf.d/50-middle.conf` +- `/etc/containers/registries.rootless.conf.d/20-first.conf` +- `/etc/containers/registries.rootless.conf.d/70-last.conf` + +They will be read in the order of `20-first.conf`, `50-middle.conf`, `70-last.conf`, +the directory path itself does not matter for the order, only the basename. + +Specified fields in a conf file will overwrite any previous setting. +For instance, setting the `unqualified-search-registries` in +`/etc/containers/registries.conf.d/myregistries.conf` will overwrite previous +settings in `/etc/containers/registries.conf`. The `[[registry]]` tables merged +by overwriting existing items if the prefixes are identical while new ones are +added. + +If `CONTAINERS_REGISTRIES_CONF_OVERRIDE` is set, it specifies an additional path that is being read last, +unless overridden by application-specific configuration. + ### GLOBAL SETTINGS diff --git a/image/docs/containers-registries.conf.d.5 b/image/docs/containers-registries.conf.d.5 new file mode 100644 index 0000000000..e2a02642c9 --- /dev/null +++ b/image/docs/containers-registries.conf.d.5 @@ -0,0 +1 @@ +.so man5/containers-registries.conf.5 diff --git a/image/docs/containers-registries.conf.d.5.md b/image/docs/containers-registries.conf.d.5.md deleted file mode 100644 index 2d4f2695ff..0000000000 --- a/image/docs/containers-registries.conf.d.5.md +++ /dev/null @@ -1,34 +0,0 @@ -% CONTAINERS-REGISTRIES.CONF.D 5 -% Valentin Rothberg -% Mar 2020 - -# NAME -containers-registries.conf.d - directory for drop-in registries.conf files - -# DESCRIPTION -CONTAINERS-REGISTRIES.CONF.D is a system-wide directory for drop-in -configuration files in the `containers-registries.conf(5)` format. - -By default, the directory is located at `/etc/containers/registries.conf.d`. - -# CONFIGURATION PRECEDENCE - -Once the main configuration at `/etc/containers/registries.conf` is loaded, the -files in `/etc/containers/registries.conf.d` are loaded in alpha-numerical -order. Then the conf files in `$HOME/.config/containers/registries.conf.d` are loaded in alpha-numerical order, if they exist. If the `$HOME/.config/containers/registries.conf` is loaded, only the conf files under `$HOME/.config/containers/registries.conf.d` are loaded in alpha-numerical order. -Specified fields in a conf file will overwrite any previous setting. Note -that only files with the `.conf` suffix are loaded, other files and -sub-directories are ignored. - -For instance, setting the `unqualified-search-registries` in -`/etc/containers/registries.conf.d/myregistries.conf` will overwrite previous -settings in `/etc/containers/registries.conf`. The `[[registry]]` tables merged -by overwriting existing items if the prefixes are identical while new ones are -added. - -# SEE ALSO -`containers-registries.conf(5)` - -# HISTORY - -Mar 2020, Originally compiled by Valentin Rothberg From f73db9b7e719675193d76250e06acb66292ccb43 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Tue, 21 Apr 2026 16:58:31 +0200 Subject: [PATCH 07/10] common: fix typo in registries.conf message The config file name is registries.conf not registry.conf. Signed-off-by: Paul Holzinger --- common/pkg/auth/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/pkg/auth/auth.go b/common/pkg/auth/auth.go index b6f7228546..1a04716150 100644 --- a/common/pkg/auth/auth.go +++ b/common/pkg/auth/auth.go @@ -393,7 +393,7 @@ func Logout(systemContext *types.SystemContext, opts *LogoutOptions, args []stri func defaultRegistryWhenUnspecified(systemContext *types.SystemContext) (string, error) { registriesFromFile, err := sysregistriesv2.UnqualifiedSearchRegistries(systemContext) if err != nil { - return "", fmt.Errorf("getting registry from registry.conf, please specify a registry: %w", err) + return "", fmt.Errorf("getting registry from registries.conf, please specify a registry: %w", err) } if len(registriesFromFile) == 0 { return "", errors.New("no registries found in registries.conf, a registry must be provided") From b33d9fed40d2c521368f99da1116eaed95c26d7a Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Tue, 21 Apr 2026 17:06:50 +0200 Subject: [PATCH 08/10] image: deprecate UpdateRegistriesConf() We already respect the CONTAINERS_REGISTRIES_CONF in the actual file reading code so this function should not be used anymore. Signed-off-by: Paul Holzinger --- image/pkg/cli/environment/environment.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/image/pkg/cli/environment/environment.go b/image/pkg/cli/environment/environment.go index 3551d98344..a934e74627 100644 --- a/image/pkg/cli/environment/environment.go +++ b/image/pkg/cli/environment/environment.go @@ -11,6 +11,10 @@ import ( // context, unless already set. Possible values are, in priority and only if // set, the CONTAINERS_REGISTRIES_CONF or REGISTRIES_CONFIG_PATH environment // variable. +// +// Deprecated: The registries.conf parsing code in pkg/sysregistriesv2 already +// reads CONTAINERS_REGISTRIES_CONF. REGISTRIES_CONFIG_PATH should not be used +// anymore. func UpdateRegistriesConf(sys *types.SystemContext) error { if sys == nil { return errors.New("internal error: UpdateRegistriesConf: nil argument") From b50e85afa8bdabf61f7892b0ea234a28f333b354 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Tue, 21 Apr 2026 17:08:20 +0200 Subject: [PATCH 09/10] common: remove hard coded registries.conf override The CONTAINERS_REGISTRIES_CONF env is already read by the config parser, support for REGISTRIES_CONFIG_PATH is removed. Signed-off-by: Paul Holzinger --- common/libimage/runtime.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/common/libimage/runtime.go b/common/libimage/runtime.go index 3378e6120a..90ec713f0a 100644 --- a/common/libimage/runtime.go +++ b/common/libimage/runtime.go @@ -48,21 +48,6 @@ type RuntimeOptions struct { SystemContext *types.SystemContext } -// setRegistriesConfPath sets the registries.conf path for the specified context. -func setRegistriesConfPath(systemContext *types.SystemContext) { - if systemContext.SystemRegistriesConfPath != "" { - return - } - if envOverride, ok := os.LookupEnv("CONTAINERS_REGISTRIES_CONF"); ok { - systemContext.SystemRegistriesConfPath = envOverride - return - } - if envOverride, ok := os.LookupEnv("REGISTRIES_CONFIG_PATH"); ok { - systemContext.SystemRegistriesConfPath = envOverride - return - } -} - // Runtime is responsible for image management and storing them in a containers // storage. type Runtime struct { @@ -119,8 +104,6 @@ func RuntimeFromStore(store storage.Store, options *RuntimeOptions) (*Runtime, e systemContext.BigFilesTemporaryDir = tmpdir } - setRegistriesConfPath(&systemContext) - return &Runtime{ store: store, systemContext: systemContext, From 79cd110469514d7e5d8fc7441c80904ed1678ed5 Mon Sep 17 00:00:00 2001 From: Paul Holzinger Date: Wed, 22 Apr 2026 18:21:39 +0200 Subject: [PATCH 10/10] image: fix typo in registries.conf v1 format error message The file name is registries.conf. Signed-off-by: Paul Holzinger --- image/pkg/sysregistriesv2/system_registries_v2.go | 2 +- image/pkg/sysregistriesv2/system_registries_v2_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/image/pkg/sysregistriesv2/system_registries_v2.go b/image/pkg/sysregistriesv2/system_registries_v2.go index ec99540940..f489246052 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2.go +++ b/image/pkg/sysregistriesv2/system_registries_v2.go @@ -880,7 +880,7 @@ func loadConfigFile(item *configfile.Item) (*parsedConfig, error) { if combinedTOML.V1RegistriesConf.hasSetField() { // V1 format is no longer supported, produce hard error so callers know they must update the config. - return nil, &InvalidRegistries{s: "registry must be in v2 format but is in v1"} + return nil, &InvalidRegistries{s: "registries.conf must be in v2 format but is in v1"} } res := parsedConfig{partialV2: combinedTOML.V2RegistriesConf} diff --git a/image/pkg/sysregistriesv2/system_registries_v2_test.go b/image/pkg/sysregistriesv2/system_registries_v2_test.go index 5d451b9109..72007b0597 100644 --- a/image/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/image/pkg/sysregistriesv2/system_registries_v2_test.go @@ -468,7 +468,7 @@ func TestV1SyntaxErrors(t *testing.T) { SystemRegistriesConfPath: c, SystemRegistriesConfDirPath: "testdata/this-does-not-exist", }) - assert.ErrorContains(t, err, "registry must be in v2 format but is in v1", c) + assert.ErrorContains(t, err, "registries.conf must be in v2 format but is in v1", c) } }