From 414560f1ff15267343ada3bc0380c46d23d158b1 Mon Sep 17 00:00:00 2001 From: Valentin Delaye Date: Sat, 20 Jun 2026 15:17:00 +0200 Subject: [PATCH] Add support for registries config drop-in files Signed-off-by: Valentin Delaye --- .../java/land/oras/auth/RegistriesConf.java | 86 +++++++++--- src/test/java/land/oras/TestUtils.java | 40 ++++-- .../land/oras/auth/RegistriesConfTest.java | 128 +++++++++++++----- 3 files changed, 187 insertions(+), 67 deletions(-) diff --git a/src/main/java/land/oras/auth/RegistriesConf.java b/src/main/java/land/oras/auth/RegistriesConf.java index 95929d37..f275cc71 100644 --- a/src/main/java/land/oras/auth/RegistriesConf.java +++ b/src/main/java/land/oras/auth/RegistriesConf.java @@ -23,8 +23,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -68,23 +70,22 @@ public class RegistriesConf { } /** - * Create a new RegistriesConf instance by loading configuration from the specified paths. - * @param configPaths The list of paths to load configuration from. + * Create a new RegistriesConf instance by merging configuration from all provided paths that exist. + * Files are merged in list order: later entries override scalar values and augment collections. + * @param configPaths The ordered list of paths to load and merge. * @return A new RegistriesConf instance. */ public static RegistriesConf newConf(List configPaths) { - - // Take the first path found: https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md + Config config = new Config(); for (Path configPath : configPaths) { LOG.debug("Checking for registries config file at: {}", configPath); if (Files.exists(configPath)) { ConfigFile configFile = TomlUtils.fromToml(configPath, ConfigFile.class); LOG.debug("Loaded registries config file: {}", configPath); - return new RegistriesConf(Config.load(configFile)); + config.merge(configFile); } } - // Empty config - return new RegistriesConf(new Config()); + return new RegistriesConf(config); } /** @@ -104,18 +105,48 @@ public static RegistriesConf newConf() { } /** - * Returns the ordered list of registries.conf paths to search when {@code CONTAINERS_REGISTRIES_CONF} is not set. - * The user-local path (under {@code $HOME}) is tried first; the system-wide path is always included as fallback. + * Returns the ordered list of registries.conf paths to load and merge when {@code CONTAINERS_REGISTRIES_CONF} + * is not set. Follows the containers/image search order: + *
    + *
  1. {@code /etc/containers/registries.conf}
  2. + *
  3. {@code /etc/containers/registries.conf.d/*.conf} (alpha-numerical order)
  4. + *
  5. {@code $HOME/.config/containers/registries.conf} (when {@code HOME} is set)
  6. + *
  7. {@code $HOME/.config/containers/registries.conf.d/*.conf} (alpha-numerical order)
  8. + *
* - * @return list of candidate paths. + * @return ordered list of candidate paths. */ private static List defaultConfPaths() { - Path globalPath = Path.of("/etc/containers/registries.conf"); + List paths = new ArrayList<>(); + paths.add(Path.of("/etc/containers/registries.conf")); + paths.addAll(dropInFiles(Path.of("/etc/containers/registries.conf.d"))); String home = System.getenv("HOME"); if (home != null) { - return List.of(Path.of(home, ".config", "containers", "registries.conf"), globalPath); + paths.add(Path.of(home, ".config", "containers", "registries.conf")); + paths.addAll(dropInFiles(Path.of(home, ".config", "containers", "registries.conf.d"))); + } + return paths; + } + + /** + * Lists all {@code *.conf} files in the given drop-in directory, sorted in alpha-numerical order. + * Returns an empty list if the directory does not exist or cannot be read. + * + * @param dir the drop-in directory to scan. + * @return sorted list of {@code *.conf} paths found in {@code dir}. + */ + private static List dropInFiles(Path dir) { + if (!Files.isDirectory(dir)) { + return List.of(); + } + try (var stream = Files.list(dir)) { + return stream.filter(p -> p.getFileName().toString().endsWith(".conf")) + .sorted() + .collect(Collectors.toList()); + } catch (IOException e) { + LOG.warn("Failed to list drop-in directory {}: {}", dir, e.getMessage()); + return List.of(); } - return List.of(globalPath); } /** @@ -642,23 +673,34 @@ private Config() {} */ public static Config load(ConfigFile configFile) throws OrasException { Config config = new Config(); + config.merge(configFile); + return config; + } + + /** + * Merges the given {@link ConfigFile} into this config. + * Collections (registries, unqualified registries) are extended; scalar values (aliases, short-name-mode) + * use last-wins semantics so later drop-in files can override earlier ones. + * + * @param configFile the parsed config file to merge in. + */ + void merge(ConfigFile configFile) { if (configFile.unqualifiedRegistries != null) { - LOG.trace("Loading unqualified registries: {}", configFile.unqualifiedRegistries); - config.unqualifiedRegistries.addAll(configFile.unqualifiedRegistries); + LOG.trace("Merging unqualified registries: {}", configFile.unqualifiedRegistries); + unqualifiedRegistries.addAll(configFile.unqualifiedRegistries); } if (configFile.aliases != null) { - LOG.trace("Loading registry aliases: {}", configFile.aliases); - config.aliases.putAll(configFile.aliases); + LOG.trace("Merging registry aliases: {}", configFile.aliases); + aliases.putAll(configFile.aliases); } if (configFile.registries != null) { - LOG.trace("Loading registry configurations: {}", configFile.registries); - config.registries.addAll(configFile.registries); + LOG.trace("Merging registry configurations: {}", configFile.registries); + registries.addAll(configFile.registries); } if (configFile.shortNameMode != null) { - LOG.trace("Loading short name mode: {}", configFile.shortNameMode); - config.shortNameMode = configFile.shortNameMode; + LOG.trace("Merging short name mode: {}", configFile.shortNameMode); + shortNameMode = configFile.shortNameMode; } - return config; } } } diff --git a/src/test/java/land/oras/TestUtils.java b/src/test/java/land/oras/TestUtils.java index 308cba94..22d45eb8 100644 --- a/src/test/java/land/oras/TestUtils.java +++ b/src/test/java/land/oras/TestUtils.java @@ -67,6 +67,24 @@ public static synchronized void createRegistriesConfFile(Path homeDir, String co } } + /** + * Create a drop-in registries.conf file under {@code $HOME/.config/containers/registries.conf.d/}. + * The drop-in directory is created if it does not already exist. + * + * @param homeDir the home directory that contains (or will contain) the drop-in directory + * @param filename the filename to write inside the drop-in directory (e.g. {@code "10-extra.conf"}) + * @param content the TOML content to write + */ + public static synchronized void createDropInConfFile(Path homeDir, String filename, String content) { + try { + Path dropInDir = homeDir.resolve(".config").resolve("containers").resolve("registries.conf.d"); + Files.createDirectories(dropInDir); + Files.writeString(dropInDir.resolve(filename), content); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * Execute the given action with the HOME environment variable set to the given home directory. * @param homeDir the home directory to set in the HOME environment variable @@ -74,15 +92,17 @@ public static synchronized void createRegistriesConfFile(Path homeDir, String co * @throws Exception if any exception occurs during the execution of the action */ public static synchronized void withHome(Path homeDir, Runnable action) throws Exception { - new EnvironmentVariables() - .set("HOME", homeDir.toAbsolutePath().toString()) - .remove("CONTAINERS_REGISTRIES_CONF") - .execute(() -> { - try { - action.run(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); + synchronized (TestUtils.class) { + new EnvironmentVariables() + .set("HOME", homeDir.toAbsolutePath().toString()) + .remove("CONTAINERS_REGISTRIES_CONF") + .execute(() -> { + try { + action.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } } } diff --git a/src/test/java/land/oras/auth/RegistriesConfTest.java b/src/test/java/land/oras/auth/RegistriesConfTest.java index d6ac4029..e4d7c560 100644 --- a/src/test/java/land/oras/auth/RegistriesConfTest.java +++ b/src/test/java/land/oras/auth/RegistriesConfTest.java @@ -60,9 +60,9 @@ void shouldReadAlias() throws Exception { TestUtils.withHome(homeDir, () -> { RegistriesConf conf = RegistriesConf.newConf(); assertNotNull(conf); - assertEquals(1, conf.getAliases().size()); - assertEquals("docker.io/library/alpine", conf.getAliases().get("alpine")); + // Use membership checks: /etc/containers/registries.conf may exist and contribute extra aliases assertTrue(conf.hasAlias("alpine")); + assertEquals("docker.io/library/alpine", conf.getAliases().get("alpine")); }); } @@ -71,24 +71,72 @@ void shouldReadUnqualifiedSearchRegistriesFromHome() throws Exception { TestUtils.withHome(homeDir, () -> { RegistriesConf conf = RegistriesConf.newConf(); assertNotNull(conf); - assertEquals(1, conf.getUnqualifiedRegistries().size()); - assertEquals("docker.io", conf.getUnqualifiedRegistries().get(0)); + // Use contains check: /etc/containers/registries.conf may exist and contribute extra registries + assertTrue(conf.getUnqualifiedRegistries().contains("docker.io")); + }); + } + + @Test + void shouldLoadDropInConfFiles(@TempDir Path dropInHomeDir) throws Exception { + // language=toml + TestUtils.createRegistriesConfFile(dropInHomeDir, "unqualified-search-registries = [\"docker.io\"]"); + // language=toml + TestUtils.createDropInConfFile( + dropInHomeDir, + "10-extra.conf", + """ + [aliases] + "myapp"="registry.example.com/myapp" + """); + + TestUtils.withHome(dropInHomeDir, () -> { + RegistriesConf conf = RegistriesConf.newConf(); + assertNotNull(conf); + // Use contains check: /etc/containers/registries.conf may exist and contribute extra registries + assertTrue(conf.getUnqualifiedRegistries().contains("docker.io")); + assertTrue(conf.hasAlias("myapp")); + assertEquals("registry.example.com/myapp", conf.getAliases().get("myapp")); + }); + } + + @Test + void shouldLoadDropInConfFilesInAlphaNumericalOrder(@TempDir Path dropInHomeDir) throws Exception { + TestUtils.createRegistriesConfFile(dropInHomeDir, ""); + // language=toml + TestUtils.createDropInConfFile( + dropInHomeDir, + "01-first.conf", + """ + [aliases] + "foo"="registry.first.com/foo" + """); + // language=toml + TestUtils.createDropInConfFile( + dropInHomeDir, + "02-second.conf", + """ + [aliases] + "foo"="registry.second.com/foo" + """); + + TestUtils.withHome(dropInHomeDir, () -> { + RegistriesConf conf = RegistriesConf.newConf(); + // 02-second.conf is loaded after 01-first.conf so its alias wins (last-wins) + assertEquals("registry.second.com/foo", conf.getAliases().get("foo")); }); } @Test void shouldFallBackToGlobalPathWhenHomeIsAbsent() throws Exception { - new EnvironmentVariables() - .remove("HOME") - .remove("CONTAINERS_REGISTRIES_CONF") - .execute(() -> { - RegistriesConf conf = RegistriesConf.newConf(); - assertNotNull(conf); - // /etc/containers/registries.conf does not exist in the test environment, - // so the result is an empty config — but the code path is exercised. - assertTrue(conf.getUnqualifiedRegistries().isEmpty()); - assertTrue(conf.getAliases().isEmpty()); - }); + synchronized (TestUtils.class) { + new EnvironmentVariables() + .remove("HOME") + .remove("CONTAINERS_REGISTRIES_CONF") + .execute(() -> { + RegistriesConf conf = RegistriesConf.newConf(); + assertNotNull(conf); + }); + } } @Test @@ -104,16 +152,21 @@ void shouldUseContainersRegistriesConfWhenSet(@TempDir Path customDir) throws Ex Path customFile = customDir.resolve("registries.conf"); Files.writeString(customFile, customContent); - new EnvironmentVariables() - .set("CONTAINERS_REGISTRIES_CONF", customFile.toAbsolutePath().toString()) - .execute(() -> { - RegistriesConf conf = RegistriesConf.newConf(); - assertNotNull(conf); - assertEquals(1, conf.getUnqualifiedRegistries().size()); - assertEquals("quay.io", conf.getUnqualifiedRegistries().get(0)); - assertTrue(conf.hasAlias("busybox")); - assertEquals("quay.io/library/busybox", conf.getAliases().get("busybox")); - }); + synchronized (TestUtils.class) { + new EnvironmentVariables() + .set( + "CONTAINERS_REGISTRIES_CONF", + customFile.toAbsolutePath().toString()) + .execute(() -> { + RegistriesConf conf = RegistriesConf.newConf(); + assertNotNull(conf); + assertEquals(1, conf.getUnqualifiedRegistries().size()); + assertEquals("quay.io", conf.getUnqualifiedRegistries().get(0)); + assertTrue(conf.hasAlias("busybox")); + assertEquals( + "quay.io/library/busybox", conf.getAliases().get("busybox")); + }); + } } @Test @@ -125,15 +178,20 @@ void containersRegistriesConfTakesPrecedenceOverHome(@TempDir Path customDir) th Path customFile = customDir.resolve("registries.conf"); Files.writeString(customFile, customContent); - new EnvironmentVariables() - .set("CONTAINERS_REGISTRIES_CONF", customFile.toAbsolutePath().toString()) - .set("HOME", homeDir.toAbsolutePath().toString()) - .execute(() -> { - RegistriesConf conf = RegistriesConf.newConf(); - // Must reflect the custom file, not the HOME-based one (which has "docker.io") - assertEquals(1, conf.getUnqualifiedRegistries().size()); - assertEquals("custom.io", conf.getUnqualifiedRegistries().get(0)); - assertFalse(conf.hasAlias("alpine")); - }); + synchronized (TestUtils.class) { + new EnvironmentVariables() + .set( + "CONTAINERS_REGISTRIES_CONF", + customFile.toAbsolutePath().toString()) + .set("HOME", homeDir.toAbsolutePath().toString()) + .execute(() -> { + RegistriesConf conf = RegistriesConf.newConf(); + // Must reflect the custom file, not the HOME-based one (which has "docker.io") + assertEquals(1, conf.getUnqualifiedRegistries().size()); + assertEquals( + "custom.io", conf.getUnqualifiedRegistries().get(0)); + assertFalse(conf.hasAlias("alpine")); + }); + } } }