diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentProperties.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentProperties.java index 9f20aae97..cfcab639d 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentProperties.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentProperties.java @@ -16,11 +16,15 @@ package org.springframework.cloud.config.server.environment; +import java.util.ArrayList; +import java.util.List; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.cloud.config.server.support.EnvironmentRepositoryProperties; /** * @author Clay McCoy + * @author Geonwook Ham */ @ConfigurationProperties("spring.cloud.config.server.awss3") public class AwsS3EnvironmentProperties implements EnvironmentRepositoryProperties { @@ -48,6 +52,16 @@ public class AwsS3EnvironmentProperties implements EnvironmentRepositoryProperti private int order = DEFAULT_ORDER; + private List searchPaths = new ArrayList<>(); + + public List getSearchPaths() { + return searchPaths; + } + + public void setSearchPaths(List searchPaths) { + this.searchPaths = searchPaths; + } + public String getRegion() { return region; } diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepository.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepository.java index 9b26a94b5..cb5617794 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepository.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepository.java @@ -20,8 +20,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.function.Consumer; import org.apache.commons.logging.Log; @@ -30,6 +33,11 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.S3Object; import org.springframework.beans.factory.config.YamlProcessor; import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; @@ -38,18 +46,24 @@ import org.springframework.cloud.config.server.config.ConfigServerProperties; import org.springframework.core.Ordered; import org.springframework.core.io.InputStreamResource; +import org.springframework.util.AntPathMatcher; import org.springframework.util.ObjectUtils; +import org.springframework.util.PathMatcher; import org.springframework.util.StringUtils; import static org.springframework.cloud.config.server.environment.AwsS3EnvironmentRepository.PATH_SEPARATOR; + /** * @author Clay McCoy * @author Scott Frederick * @author Daniel Aiken + * @author Geonwook Ham */ public class AwsS3EnvironmentRepository implements EnvironmentRepository, Ordered, SearchPathLocator { + private final PathMatcher pathMatcher = new AntPathMatcher(); + protected static final String PATH_SEPARATOR = "/"; private static final Log LOG = LogFactory.getLog(AwsS3EnvironmentRepository.class); @@ -66,16 +80,24 @@ public class AwsS3EnvironmentRepository implements EnvironmentRepository, Ordere protected int order = Ordered.LOWEST_PRECEDENCE; + private final List searchPaths; + public AwsS3EnvironmentRepository(S3Client s3Client, String bucketName, ConfigServerProperties server) { this(s3Client, bucketName, false, server); } public AwsS3EnvironmentRepository(S3Client s3Client, String bucketName, boolean useApplicationAsDirectory, ConfigServerProperties server) { + this(s3Client, bucketName, useApplicationAsDirectory, server, null); + } + + public AwsS3EnvironmentRepository(S3Client s3Client, String bucketName, boolean useApplicationAsDirectory, + ConfigServerProperties server, List searchPaths) { this.s3Client = s3Client; this.bucketName = bucketName; this.serverProperties = server; this.useApplicationAsDirectory = useApplicationAsDirectory; + this.searchPaths = (searchPaths == null ? Collections.emptyList() : searchPaths); } @Override @@ -97,8 +119,8 @@ public Environment findOne(String specifiedApplication, String specifiedProfiles String[] profileArray = parseProfiles(profiles); List apps = Arrays.asList(StringUtils.commaDelimitedListToStringArray(application.replace(" ", ""))); - Collections.reverse(apps); - if (!apps.contains(serverProperties.getDefaultApplicationName())) { + if (searchPaths.isEmpty() && !apps.contains(serverProperties.getDefaultApplicationName())) { + Collections.reverse(apps); apps = new ArrayList<>(apps); apps.add(serverProperties.getDefaultApplicationName()); } @@ -126,6 +148,17 @@ public Environment findOne(String specifiedApplication, String specifiedProfiles private void addPropertySources(Environment environment, List apps, String[] profiles, List labels) { + if (!this.searchPaths.isEmpty()) { + for (String label : labels) { + for (String profile : profiles) { + for (String app : apps) { + List s3ConfigFiles = getS3ConfigFileWithSearchPaths(app, profile, label); + addPropertySource(environment, s3ConfigFiles); + } + } + } + return; + } for (String label : labels) { // If we have profiles, add property sources with those profiles for (String profile : profiles) { @@ -163,15 +196,21 @@ private void addPropertySourcesForApps(List apps, Consumer addPr } private void addProfileSpecificPropertySource(Environment environment, String app, String profile, String label) { - List s3ConfigFiles = getS3ConfigFile(app, profile, label, this::getS3PropertiesOrJsonConfigFile, - this::getProfileSpecificS3ConfigFileYaml); + if (!searchPaths.isEmpty() && app.equals(serverProperties.getDefaultApplicationName())) { + return; + } + List s3ConfigFiles = searchPaths.isEmpty() + ? getS3ConfigFile(app, profile, label, this::getS3PropertiesOrJsonConfigFile, this::getProfileSpecificS3ConfigFileYaml) + : getS3ConfigFileWithSearchPaths(app, profile, label); addPropertySource(environment, s3ConfigFiles); } - private void addNonProfileSpecificPropertySource(Environment environment, String app, String profile, - String label) { - List s3ConfigFiles = getS3ConfigFile(app, profile, label, - this::getNonProfileSpecificPropertiesOrJsonConfigFile, this::getNonProfileSpecificS3ConfigFileYaml); + private void addNonProfileSpecificPropertySource(Environment environment, String app, String profile, String label) { + List s3ConfigFiles = searchPaths.isEmpty() + ? getS3ConfigFile(app, profile, label, + this::getNonProfileSpecificPropertiesOrJsonConfigFile, + this::getNonProfileSpecificS3ConfigFileYaml) + : Collections.emptyList(); addPropertySource(environment, s3ConfigFiles); } @@ -215,6 +254,204 @@ private List getS3ConfigFile(String application, String profile, S } + private List getS3ConfigFileWithSearchPaths( + String application, String profile, String label) { + + List result = new ArrayList<>(); + Set seenKeys = new LinkedHashSet<>(); + + for (String template : this.searchPaths) { + String pattern = template + .replace("{application}", application) + .replace("{profile}", profile == null ? "" : profile) + .replace("{label}", label == null ? "" : label); + + if (!pathMatcher.isPattern(pattern)) { + boolean fileFound = false; + for (String ext : List.of(".properties", ".json", ".yml", ".yaml")) { + String key = pattern.endsWith(ext) ? pattern : pattern + ext; + if (!seenKeys.add(key)) { + continue; + } + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build()); + result.addAll(wrapKeyWithConfigFiles(key, application, profile, label)); + fileFound = true; + break; + } + catch (S3Exception e) { + int status = e.statusCode(); + if (status != 404 && status != 403) { + throw e; + } + } + } + if (fileFound) { + continue; + } + + String dirPrefix = pattern.endsWith("/") ? pattern : pattern + "/"; + String token = null; + do { + ListObjectsV2Response resp = s3Client.listObjectsV2( + ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(dirPrefix) + .continuationToken(token) + .build()); + for (S3Object obj : resp.contents()) { + String key = obj.key(); + if (!hasSupportedExtension(key)) { + continue; + } + if (seenKeys.add(key)) { + result.addAll(wrapKeyWithConfigFiles(key, application, profile, label)); + } + } + token = resp.nextContinuationToken(); + } while (token != null); + + continue; + } + + if (pattern.endsWith(".*")) { + String base = pattern.substring(0, pattern.length() - 2); + for (String ext : List.of(".properties", ".json", ".yml", ".yaml")) { + String key = base + ext; + if (!seenKeys.add(key)) { + continue; + } + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build()); + result.addAll(wrapKeyWithConfigFiles(key, application, profile, label)); + break; + } + catch (S3Exception e) { + int status = e.statusCode(); + if (status != 404 && status != 403) { + throw e; + } + } + } + continue; + } + + String prefix = extractPrefix(pattern); + String token = null; + do { + ListObjectsV2Response resp = s3Client.listObjectsV2( + ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(prefix) + .continuationToken(token) + .build()); + for (S3Object obj : resp.contents()) { + String key = obj.key(); + if (!pathMatcher.match(pattern, key) || !hasSupportedExtension(key)) { + continue; + } + if (seenKeys.add(key)) { + result.addAll(wrapKeyWithConfigFiles(key, application, profile, label)); + } + } + token = resp.nextContinuationToken(); + } while (token != null); + } + + return result; + } + + private boolean hasSupportedExtension(String key) { + return key.endsWith(".properties") + || key.endsWith(".json") + || key.endsWith(".yml") + || key.endsWith(".yaml"); + } + + private List wrapKeyWithConfigFiles( + String key, + String application, + String profile, + String label) { + + if (key.endsWith(".yml") || key.endsWith(".yaml")) { + List files = new ArrayList<>(); + files.addAll(getProfileSpecificYamlFromKey(key, application, profile, label)); + files.addAll(getNonProfileSpecificYamlFromKey(key, application, profile, label)); + return files; + } + return createConfigFileFromKey(key, application, profile, label) + .map(Collections::singletonList) + .orElseGet(Collections::emptyList); + } + + + private List getProfileSpecificYamlFromKey( + String key, String application, String profile, String label) { + + YamlConfigFileFromKey config = new YamlConfigFileFromKey( + key, application, profile, label, + bucketName, useApplicationAsDirectory, s3Client, + properties -> YamlS3ConfigFile.profileMatchesActivateProperty(profile, properties) + ? YamlProcessor.MatchStatus.FOUND + : YamlProcessor.MatchStatus.NOT_FOUND + ); + config.setShouldIncludeWithEmptyProperties(false); + return List.of(config); + } + + private List getNonProfileSpecificYamlFromKey( + String key, String application, String profile, String label) { + + YamlConfigFileFromKey config = new YamlConfigFileFromKey( + key, application, profile, label, + bucketName, useApplicationAsDirectory, s3Client, + properties -> !YamlS3ConfigFile.onProfilePropertyExists(properties) + ? YamlProcessor.MatchStatus.FOUND + : YamlProcessor.MatchStatus.NOT_FOUND + ); + return List.of(config); + } + + private String extractPrefix(String pattern) { + int idx = pattern.indexOf('*'); + int q = pattern.indexOf('?'); + if (q != -1 && (idx == -1 || q < idx)) { + idx = q; + } + if (idx <= 0) { + return ""; + } + int slash = pattern.lastIndexOf('/', idx); + return (slash == -1 ? "" : pattern.substring(0, slash + 1)); + } + + + private Optional createConfigFileFromKey(String key, + String application, String profile, String label) { + String ext = key.substring(key.lastIndexOf('.') + 1); + if ("properties".equalsIgnoreCase(ext)) { + return Optional.of(new PropertyConfigFileFromKey( + key, application, profile, label, bucketName, s3Client)); + } + if ("json".equalsIgnoreCase(ext)) { + return Optional.of(new JsonConfigFileFromKey( + key, application, profile, label, bucketName, s3Client)); + } + if ("yml".equalsIgnoreCase(ext) || "yaml".equalsIgnoreCase(ext)) { + return Optional.of(new YamlConfigFileFromKey( + key, application, profile, label, bucketName, s3Client)); + } + return Optional.empty(); + } + + private List getNonProfileSpecificS3ConfigFileYaml(String application, String profile, String label) { List configFiles = new ArrayList<>(); @@ -434,10 +671,18 @@ private String createPropertySourceName(String app, String profile) { class PropertyS3ConfigFile extends S3ConfigFile { + PropertyS3ConfigFile(String application, String profile, String label, + String bucketName, boolean useApplicationAsDirectory, + S3Client s3Client) { + this(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, true); + } + PropertyS3ConfigFile(String application, String profile, String label, String bucketName, - boolean useApplicationAsDirectory, S3Client s3Client) { + boolean useApplicationAsDirectory, S3Client s3Client, boolean callReadImmediately) { super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client); - this.properties = read(); + if (callReadImmediately) { + this.properties = read(); + } } @Override @@ -469,17 +714,18 @@ class YamlS3ConfigFile extends S3ConfigFile { YamlS3ConfigFile(String application, String profile, String label, String bucketName, boolean useApplicationAsDirectory, S3Client s3Client) { - this(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, + this(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, true, new YamlProcessor.DocumentMatcher[] {}); } YamlS3ConfigFile(String application, String profile, String label, String bucketName, - boolean useApplicationAsDirectory, S3Client s3Client, + boolean useApplicationAsDirectory, S3Client s3Client, boolean callReadImmediately, final YamlProcessor.DocumentMatcher... documentMatchers) { super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client); this.documentMatchers = documentMatchers; - this.properties = read(); - + if (callReadImmediately) { + this.properties = read(); + } } protected static boolean profileMatchesActivateProperty(String profile, Properties properties) { @@ -520,7 +766,7 @@ class ProfileSpecificYamlDocumentS3ConfigFile extends YamlS3ConfigFile { ProfileSpecificYamlDocumentS3ConfigFile(String application, String profile, String label, String bucketName, boolean useApplicationAsDirectory, S3Client s3Client) { - super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, + super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, true, properties -> profileMatchesActivateProperty(profile, properties) ? YamlProcessor.MatchStatus.FOUND : YamlProcessor.MatchStatus.NOT_FOUND); } @@ -541,7 +787,7 @@ class NonProfileSpecificYamlDocumentS3ConfigFile extends YamlS3ConfigFile { NonProfileSpecificYamlDocumentS3ConfigFile(String application, String profile, String label, String bucketName, boolean useApplicationAsDirectory, S3Client s3Client) { - super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, + super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, true, properties -> !onProfilePropertyExists(properties) ? YamlProcessor.MatchStatus.FOUND : YamlProcessor.MatchStatus.NOT_FOUND); } @@ -552,7 +798,7 @@ class ProfileSpecificYamlS3ConfigFile extends YamlS3ConfigFile { ProfileSpecificYamlS3ConfigFile(String application, String profile, String label, String bucketName, boolean useApplicationAsDirectory, S3Client s3Client) { - super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, + super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, true, properties -> !onProfilePropertyExists(properties) ? YamlProcessor.MatchStatus.ABSTAIN : profileMatchesActivateProperty(profile, properties) ? YamlProcessor.MatchStatus.FOUND : YamlProcessor.MatchStatus.NOT_FOUND); @@ -570,9 +816,102 @@ class JsonS3ConfigFile extends YamlS3ConfigFile { this.properties = read(); } + JsonS3ConfigFile(String application, String profile, String label, String bucketName, + boolean useApplicationAsDirectory, S3Client s3Client, boolean callReadImmediately) { + super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, callReadImmediately); + } + @Override protected List getExtensions() { return List.of("json"); } } + +class PropertyConfigFileFromKey extends PropertyS3ConfigFile { + + private final String key; + + PropertyConfigFileFromKey(String key, + String application, + String profile, + String label, + String bucketName, + S3Client s3Client) { + super(application, profile, label, bucketName, false, s3Client, false); + this.key = key; + this.properties = read(); + } + + @Override + public String getName() { + return "s3:" + bucketName + "/" + key; + } + + @Override + protected String buildObjectKeyPrefix() { + return key.substring(0, key.lastIndexOf('.')); + } +} + +class YamlConfigFileFromKey extends YamlS3ConfigFile { + + private final String key; + + YamlConfigFileFromKey(String key, + String application, + String profile, + String label, + String bucketName, + S3Client s3Client) { + super(application, profile, label, bucketName, false, s3Client, false); + this.key = key; + this.properties = read(); + } + + YamlConfigFileFromKey(String key, + String application, + String profile, + String label, + String bucketName, + boolean useApplicationAsDirectory, + S3Client s3Client, + YamlProcessor.DocumentMatcher... matchers) { + super(application, profile, label, bucketName, useApplicationAsDirectory, s3Client, false, matchers); + this.key = key; + this.properties = read(); + } + + @Override + public String getName() { + return "s3:" + bucketName + "/" + key; + } + + @Override + protected String buildObjectKeyPrefix() { + return key.substring(0, key.lastIndexOf('.')); + } +} + +class JsonConfigFileFromKey extends JsonS3ConfigFile { + + private final String key; + + JsonConfigFileFromKey(String key, String application, String profile, + String label, String bucketName, S3Client s3Client) { + super(application, profile, label, bucketName, false, s3Client, false); + this.key = key; + this.properties = read(); + } + + @Override + public String getName() { + return "s3:" + bucketName + "/" + key; + } + + @Override + protected String buildObjectKeyPrefix() { + return key.substring(0, key.lastIndexOf('.')); + } +} + diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryFactory.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryFactory.java index 6781b88a4..d9a9eaa83 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryFactory.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryFactory.java @@ -23,6 +23,9 @@ import static org.springframework.cloud.config.server.environment.AwsClientBuilderConfigurer.configureClientBuilder; +/** + * @author Geonwook Ham + */ public class AwsS3EnvironmentRepositoryFactory implements EnvironmentRepositoryFactory { @@ -39,7 +42,7 @@ public AwsS3EnvironmentRepository build(AwsS3EnvironmentProperties environmentPr final S3Client client = clientBuilder.build(); AwsS3EnvironmentRepository repository = new AwsS3EnvironmentRepository(client, - environmentProperties.getBucket(), environmentProperties.isUseDirectoryLayout(), server); + environmentProperties.getBucket(), environmentProperties.isUseDirectoryLayout(), server, environmentProperties.getSearchPaths()); repository.setOrder(environmentProperties.getOrder()); return repository; } diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryTests.java index b62af5403..64726c86a 100644 --- a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryTests.java +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/AwsS3EnvironmentRepositoryTests.java @@ -21,12 +21,16 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Properties; +import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; import org.testcontainers.containers.localstack.LocalStackContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -55,6 +59,7 @@ /** * @author Clay McCoy * @author Matej Nedić + * @author Geonwook Ham */ @Testcontainers @Tag("DockerRequired") @@ -488,6 +493,306 @@ public void getLocationsTest() { "default", "defaultlabel", null, new String[] { "s3://test/defaultlabel" })); } + // 1) Placeholder only + @Test + public void searchPath_placeholderOnly_shouldResolveExactFile() { + List paths = List.of("{label}/{application}-{profile}.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/foo-bar.yml", yamlContent); + + Environment env = repo.findOne("foo", "bar", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/foo-bar.yml"); + } + + // 2) Wildcard only (.properties) + @Test + public void searchPath_wildcardOnly_shouldResolveAllProperties() { + List paths = List.of("{label}/common/*.properties"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/common/a.properties", "a=1\n"); + putFiles("v1/common/b.properties", "b=2\n"); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/common/a.properties"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("v1/common/b.properties"); + } + + // 3) Placeholder + wildcard combined + @Test + public void searchPath_placeholderAndWildcard_shouldResolveMatchingKeys() { + List paths = List.of( + "{label}/{application}-{profile}.yml", + "{label}/common/*.properties" + ); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/foo-bar.yml", yamlContent); + putFiles("v1/common/foo.properties", "k=v\n"); + + Environment env = repo.findOne("foo", "bar", "v1"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/foo-bar.yml"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("v1/common/foo.properties"); + } + + // 4) Order matters + @Test + public void searchPaths_orderMatters_forPropertySourceOrder() { + List paths = List.of( + "{label}/common/*.properties", + "{label}/{application}-{profile}.yml" + ); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/common/foo.properties", "k=v\n"); + putFiles("v1/foo-bar.yml", yamlContent); + + Environment env = repo.findOne("foo", "bar", "v1"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/common/foo.properties"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("v1/foo-bar.yml"); + } + + // 5) Extension preserved + @TestFactory + public Stream searchPath_extensionPreserved() { + List exts = List.of("yml", "yaml", "properties", "json"); + return exts.stream().map(ext -> DynamicTest.dynamicTest(ext, () -> { + List paths = List.of("{label}/foo-bar." + ext); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + + String content = "key=v\n"; + if (ext.equals("yml") || ext.equals("yaml")) { + content = yamlContent; + } + else if (ext.equals("json")) { + content = jsonContent; + } + putFiles("v1/foo-bar." + ext, content); + + Environment env = repo.findOne("foo", "bar", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/foo-bar." + ext); + })); + } + + // 6) Application-as-directory layout + @Test + public void searchPaths_applicationAsDirectory_shouldStillHonorSearchPaths() { + List paths = List.of("{label}/{application}/foo.*"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", true, server, paths); + putFiles("v1/foo/foo.properties", "k=v\n"); + putFiles("v1/foo/foo.json", jsonContent); + + Environment env = repo.findOne("foo", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/foo/foo.properties"); + } + + // 7) Multi-document YAML should not be split + @Test + public void multiDocumentYaml_withSearchPaths_shouldNotSplitDocuments() throws IOException { + List paths = List.of("{label}/{application}.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + + String multi = "---\na: 1\n---\nb: 2\n"; + putFiles("lab/app.yml", multi); + + Environment env = repo.findOne("app", "", "lab"); + assertThat(env.getPropertySources()).hasSize(1); + @SuppressWarnings("unchecked") + Map map = (Map) + env.getPropertySources().get(0).getSource(); + assertThat(map).containsEntry("a", 1).containsEntry("b", 2); + } + + // 8) Deduplication across patterns + @Test + public void searchPaths_deduplication_shouldOnlyAddOnce() { + List paths = List.of( + "{label}/foo.yml", + "{label}/{application}.yml" + ); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/foo.yml", yamlContent); + + Environment env = repo.findOne("foo", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + } + + // 9) Literal stops at .properties + @Test + public void searchPaths_literalStopsAtProperties() { + List paths = List.of("{label}/{application}"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/app.properties", "foo=bar\nflag=false\n"); + putFiles("v1/app.json", jsonContent); + putFiles("v1/app.yml", yamlContent); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/app.properties"); + } + + // 10) Literal stops at .json + @Test + public void searchPaths_literalStopsAtJson() { + List paths = List.of("{label}/{application}"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/app.json", jsonContent); + putFiles("v1/app.yml", yamlContent); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/app.json"); + } + + // 11) Literal stops at .yml + @Test + public void searchPaths_literalStopsAtYaml() { + List paths = List.of("{label}/{application}"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/app.yml", yamlContent); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/app.yml"); + } + + // 12) Dot-wildcard auto-extension (.properties preferred) + @Test + public void searchPaths_dotWildcardAutoExtProperties() { + List paths = List.of("{label}/{application}.*"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/app.properties", "x=1\n"); + putFiles("v1/app.json", jsonContent); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/app.properties"); + } + + // 13) Literal directory scan + @Test + public void searchPaths_literalDirectoryScan() { + List paths = List.of("{label}/{application}"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/app/foo.properties", "a=1\n"); + putFiles("v1/app/sub/bar.yml", "bar: 2\n"); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/app/foo.properties"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("v1/app/sub/bar.yml"); + } + + // 14) Double-wildcard nested directories + @Test + public void searchPaths_doubleWildcardNested() { + List paths = List.of("{label}/**/*.properties"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("v1/foo/a.properties", "a=1\n"); + putFiles("v1/foo/sub/b.properties", "b=2\n"); + + Environment env = repo.findOne("app", "", "v1"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("v1/foo/a.properties"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("v1/foo/sub/b.properties"); + } + + // 15) Single-character wildcard + @Test + public void searchPaths_singleCharacterWildcard_shouldMatchExactlyOneChar() { + List paths = List.of("{label}/data-?.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("lab/data-a.yml", "a:1\n"); + putFiles("lab/data-b.yml", "b:2\n"); + putFiles("lab/data-10.yml", "x:3\n"); // should not match + + Environment env = repo.findOne("app", "", "lab"); + assertThat(env.getPropertySources()).hasSize(2); + assertThat(env.getPropertySources().get(0).getName()) + .contains("lab/data-a.yml"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("lab/data-b.yml"); + } + + // 16) Wildcard at start (empty prefix) + @Test + public void searchPaths_wildcardAtStart_prefixExtractionEmpty_shouldMatchAll() { + List paths = List.of("{label}/*.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("lab/app.yml", yamlContent); + putFiles("lab/other.yml", yamlContent); + + Environment env = repo.findOne("app", "", "lab"); + assertThat(env.getPropertySources()).hasSize(2); + } + + // 17) Empty label uses defaultLabel + @Test + public void searchPaths_withEmptyLabel_shouldUseDefaultLabel() { + server.setDefaultLabel("main"); + List paths = List.of("{label}/foo-bar.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("main/foo-bar.yml", yamlContent); + + Environment env = repo.findOne("foo", "", ""); + assertThat(env.getPropertySources()).hasSize(1); + assertThat(env.getPropertySources().get(0).getName()) + .contains("main/foo-bar.yml"); + } + + // 18) Multiple labels applied in reverse order + @Test + public void searchPaths_multipleLabels_shouldApplyForEachLabelInReverseOrder() { + List paths = List.of("{label}/{application}.yml"); + AwsS3EnvironmentRepository repo = + new AwsS3EnvironmentRepository(s3Client, "bucket1", false, server, paths); + putFiles("lab1/app.yml", yamlContent); + putFiles("lab2/app.yml", yamlContent); + + Environment env = repo.findOne("app", "", "lab1,lab2"); + assertThat(env.getPropertySources().get(0).getName()) + .contains("lab2/app.yml"); + assertThat(env.getPropertySources().get(1).getName()) + .contains("lab1/app.yml"); + } + private String putFiles(String fileName, String propertyContent) { toBeRemoved.add(fileName); return s3Client