Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 64 additions & 22 deletions src/main/java/land/oras/auth/RegistriesConf.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Path> 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);
}

/**
Expand All @@ -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:
* <ol>
* <li>{@code /etc/containers/registries.conf}</li>
* <li>{@code /etc/containers/registries.conf.d/*.conf} (alpha-numerical order)</li>
* <li>{@code $HOME/.config/containers/registries.conf} (when {@code HOME} is set)</li>
* <li>{@code $HOME/.config/containers/registries.conf.d/*.conf} (alpha-numerical order)</li>
* </ol>
*
* @return list of candidate paths.
* @return ordered list of candidate paths.
*/
private static List<Path> defaultConfPaths() {
Path globalPath = Path.of("/etc/containers/registries.conf");
List<Path> 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<Path> 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);
}

/**
Expand Down Expand Up @@ -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;
}
}
}
40 changes: 30 additions & 10 deletions src/test/java/land/oras/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,42 @@ 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
* @param action the action to execute with the HOME environment variable set
* @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);
}
});
}
}
}
128 changes: 93 additions & 35 deletions src/test/java/land/oras/auth/RegistriesConfTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"));
});
}
}
}
Loading