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 bb2039b1cb..427aab20d8 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 @@ -37,6 +37,7 @@ import org.springframework.cloud.config.environment.PropertySource; import org.springframework.cloud.config.server.config.ConfigServerProperties; import org.springframework.core.Ordered; +import org.springframework.core.env.Profiles; import org.springframework.core.io.InputStreamResource; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -154,6 +155,11 @@ private void addPropertySources(Environment environment, List apps, Stri addPropertySourcesForApps(apps, app -> addNonProfileSpecificPropertySource(environment, app, profile, label)); } + // Handle documents with negated profile expressions (e.g. on-profile: + // "!my-profile") + // once per label rather than once per profile to avoid duplicates + addPropertySourcesForApps(apps, + app -> addNegatedProfilePropertySource(environment, app, profiles, label)); } } } @@ -162,6 +168,29 @@ private void addPropertySourcesForApps(List apps, Consumer addPr apps.forEach(addPropertySource); } + private void addNegatedProfilePropertySource(Environment environment, String app, String[] allProfiles, + String label) { + List s3ConfigFiles = getNegatedProfileS3ConfigFileYaml(app, allProfiles, label); + addPropertySource(environment, s3ConfigFiles); + } + + private List getNegatedProfileS3ConfigFileYaml(String application, String[] allProfiles, + String label) { + List configFiles = new ArrayList<>(); + try { + S3ConfigFile configFile = new NegatedProfileYamlDocumentS3ConfigFile(application, label, bucketName, + useApplicationAsDirectory, s3Client, allProfiles); + configFiles.add(configFile); + } + catch (Exception e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Did not find negated profile yaml document in non-profile specific file using application <" + + application + "> label <" + label + ">."); + } + } + return configFiles; + } + private void addProfileSpecificPropertySource(Environment environment, String app, String profile, String label) { List s3ConfigFiles = getS3ConfigFile(app, profile, label, this::getS3PropertiesOrJsonConfigFile, this::getProfileSpecificS3ConfigFileYaml); @@ -576,3 +605,44 @@ protected List getExtensions() { } } + +class NegatedProfileYamlDocumentS3ConfigFile extends YamlS3ConfigFile { + + NegatedProfileYamlDocumentS3ConfigFile(String application, String label, String bucketName, + boolean useApplicationAsDirectory, S3Client s3Client, String[] allProfiles) { + super(application, null, label, bucketName, useApplicationAsDirectory, s3Client, properties -> { + Object onProfileValue = properties.get("spring.config.activate.on-profile"); + if (onProfileValue == null) { + onProfileValue = properties.get("spring.config.activate.onProfile"); + } + if (onProfileValue == null) { + return YamlProcessor.MatchStatus.NOT_FOUND; + } + String expression = onProfileValue.toString().trim(); + // Simple positive profile names are already handled by + // ProfileSpecificYamlDocumentS3ConfigFile. Only process complex or negated + // expressions here to avoid adding duplicate property sources. + if (isSimpleProfileName(expression)) { + return YamlProcessor.MatchStatus.NOT_FOUND; + } + boolean matches = Profiles.of(expression).matches(p -> Arrays.asList(allProfiles).contains(p)); + return matches ? YamlProcessor.MatchStatus.FOUND : YamlProcessor.MatchStatus.NOT_FOUND; + }); + } + + private static boolean isSimpleProfileName(String expression) { + return !expression.contains("!") && !expression.contains("&") && !expression.contains("|") + && !expression.contains("("); + } + + @Override + protected String buildObjectKeyPrefix() { + return super.buildObjectKeyPrefix(false); + } + + @Override + public boolean isShouldIncludeWithEmptyProperties() { + return false; + } + +} 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 905a49c63d..ee7a4020d0 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,6 +21,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Properties; import org.junit.jupiter.api.AfterEach; @@ -167,6 +168,260 @@ public void multiDocumentYaml() throws IOException { // @formatter:on } + @Test + public void negatedProfileDocumentIncludedWhenProfileNotActive() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + final Environment env = envRepo.findOne("application", "default", null); + + List propertySources = env.getPropertySources(); + Optional negatedProfileSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")) + .findFirst(); + assertThat(negatedProfileSource) + .as("negated profile document should be present when 'my-profile' is not active") + .isPresent(); + assertThat(negatedProfileSource.get().getSource().get("demo.negatedProfileMarker")) + .isEqualTo("present-when-my-profile-is-not-active"); + + Optional baseSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.base")) + .findFirst(); + assertThat(baseSource).as("non-profile-specific document should be present").isPresent(); + assertThat(baseSource.get().getSource().get("demo.base")).isEqualTo("base-value"); + } + + @Test + public void negatedProfileDocumentExcludedWhenProfileIsActive() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + final Environment env = envRepo.findOne("application", "my-profile", null); + + List propertySources = env.getPropertySources(); + boolean hasNegatedProfileMarker = propertySources.stream() + .anyMatch(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")); + assertThat(hasNegatedProfileMarker) + .as("negated profile document should NOT be present when 'my-profile' is active") + .isFalse(); + + Optional baseSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.base")) + .findFirst(); + assertThat(baseSource).as("non-profile-specific document should still be present").isPresent(); + assertThat(baseSource.get().getSource().get("demo.base")).isEqualTo("base-value"); + } + + @Test + public void negatedProfileDocumentExcludedWhenNegatedProfileIsOneOfMultipleActiveProfiles() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + // "my-profile" is among the active profiles, so "!my-profile" should not match + final Environment env = envRepo.findOne("application", "my-profile,other-profile", null); + + List propertySources = env.getPropertySources(); + boolean hasNegatedProfileMarker = propertySources.stream() + .anyMatch(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")); + assertThat(hasNegatedProfileMarker) + .as("negated profile document should NOT be present when 'my-profile' is among the active profiles") + .isFalse(); + } + + @Test + public void negatedProfileDocumentIncludedWhenNegatedProfileIsAbsentFromMultipleActiveProfiles() + throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + // neither "default" nor "other-profile" is "my-profile", so "!my-profile" should + // match + final Environment env = envRepo.findOne("application", "default,other-profile", null); + + List propertySources = env.getPropertySources(); + Optional negatedProfileSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")) + .findFirst(); + assertThat(negatedProfileSource) + .as("negated profile document should be present when 'my-profile' is absent from all active profiles") + .isPresent(); + assertThat(negatedProfileSource.get().getSource().get("demo.negatedProfileMarker")) + .isEqualTo("present-when-my-profile-is-not-active"); + } + + @Test + public void twoNegatedProfileDocumentsBothIncludedWhenNeitherProfileIsActive() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-two-negated-profiles.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + final Environment env = envRepo.findOne("application", "default", null); + + List propertySources = env.getPropertySources(); + + // Both negated profile documents match and are merged into one property source + Optional negatedSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.negatedProfileMarker") + || ps.getSource().containsKey("demo.otherNegatedMarker")) + .findFirst(); + assertThat(negatedSource) + .as("negated profile documents should be present when neither 'my-profile' nor 'other-profile' is active") + .isPresent(); + assertThat(negatedSource.get().getSource().get("demo.negatedProfileMarker")) + .isEqualTo("present-when-my-profile-is-not-active"); + assertThat(negatedSource.get().getSource().get("demo.otherNegatedMarker")) + .isEqualTo("present-when-other-profile-is-not-active"); + + Optional baseSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.base")) + .findFirst(); + assertThat(baseSource).as("non-profile-specific document should be present").isPresent(); + assertThat(baseSource.get().getSource().get("demo.base")).isEqualTo("base-value"); + } + + @Test + public void twoNegatedProfileDocumentsOnlyOneIncludedWhenOneProfileIsActive() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-two-negated-profiles.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + // "my-profile" is active, so the "!my-profile" document should be excluded + // but the "!other-profile" document should still be included + final Environment env = envRepo.findOne("application", "my-profile", null); + + List propertySources = env.getPropertySources(); + + boolean hasNegatedProfileMarker = propertySources.stream() + .anyMatch(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")); + assertThat(hasNegatedProfileMarker) + .as("'!my-profile' document should NOT be present when 'my-profile' is active") + .isFalse(); + + Optional otherNegatedSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.otherNegatedMarker")) + .findFirst(); + assertThat(otherNegatedSource) + .as("'!other-profile' document should still be present when 'my-profile' is active") + .isPresent(); + assertThat(otherNegatedSource.get().getSource().get("demo.otherNegatedMarker")) + .isEqualTo("present-when-other-profile-is-not-active"); + + Optional baseSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.base")) + .findFirst(); + assertThat(baseSource).as("non-profile-specific document should still be present").isPresent(); + assertThat(baseSource.get().getSource().get("demo.base")).isEqualTo("base-value"); + } + + @Test + public void complexProfileExpressionsAreEvaluatedCorrectly() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-complex-profile-expressions.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + // profile1 active, profile2 not active + // "profile1 & !profile2" -> true + // "!(profile1 & profile2)" -> !(true & false) -> true + // "!profile1 | !profile2" -> false | true -> true + Environment env = envRepo.findOne("application", "profile1", null); + List sources = env.getPropertySources(); + + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.andExpression"))) + .as("'profile1 & !profile2' should match when profile1 active, profile2 not active") + .isTrue(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.notAndExpression"))) + .as("'!(profile1 & profile2)' should match when not both profiles active") + .isTrue(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.orNegatedExpression"))) + .as("'!profile1 | !profile2' should match when at least one profile is not active") + .isTrue(); + } + + @Test + public void complexProfileExpressionsAreExcludedWhenNotSatisfied() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-complex-profile-expressions.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application.yaml", yamlString); + + // both profile1 and profile2 active + // "profile1 & !profile2" -> true & false -> false + // "!(profile1 & profile2)" -> !(true & true) -> false + // "!profile1 | !profile2" -> false | false -> false + Environment env = envRepo.findOne("application", "profile1,profile2", null); + List sources = env.getPropertySources(); + + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.andExpression"))) + .as("'profile1 & !profile2' should NOT match when both profiles are active") + .isFalse(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.notAndExpression"))) + .as("'!(profile1 & profile2)' should NOT match when both profiles are active") + .isFalse(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.orNegatedExpression"))) + .as("'!profile1 | !profile2' should NOT match when both profiles are active") + .isFalse(); + } + + @Test + public void negatedProfileDocumentIncludedWhenProfileNotActive_ApplicationDirVariant() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application/application.yaml", yamlString); + + final Environment env = envRepoApplicationDir.findOne("application", "default", null); + + List propertySources = env.getPropertySources(); + Optional negatedProfileSource = propertySources.stream() + .filter(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")) + .findFirst(); + assertThat(negatedProfileSource) + .as("negated profile document should be present when 'my-profile' is not active") + .isPresent(); + assertThat(negatedProfileSource.get().getSource().get("demo.negatedProfileMarker")) + .isEqualTo("present-when-my-profile-is-not-active"); + } + + @Test + public void negatedProfileDocumentExcludedWhenNegatedProfileIsOneOfMultipleActiveProfiles_ApplicationDirVariant() + throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-negated-profile.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application/application.yaml", yamlString); + + final Environment env = envRepoApplicationDir.findOne("application", "my-profile,other-profile", null); + + boolean hasNegatedProfileMarker = env.getPropertySources() + .stream() + .anyMatch(ps -> ps.getSource().containsKey("demo.negatedProfileMarker")); + assertThat(hasNegatedProfileMarker) + .as("negated profile document should NOT be present when 'my-profile' is among the active profiles") + .isFalse(); + } + + @Test + public void complexProfileExpressionsAreEvaluatedCorrectly_ApplicationDirVariant() throws IOException { + Resource resource = new ClassPathResource("awss3/application-with-complex-profile-expressions.yaml"); + String yamlString = new String(Files.readAllBytes(Paths.get(resource.getURI()))); + putFiles("application/application.yaml", yamlString); + + Environment env = envRepoApplicationDir.findOne("application", "profile1", null); + List sources = env.getPropertySources(); + + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.andExpression"))) + .as("'profile1 & !profile2' should match when profile1 active, profile2 not active") + .isTrue(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.notAndExpression"))) + .as("'!(profile1 & profile2)' should match when not both profiles active") + .isTrue(); + assertThat(sources.stream().anyMatch(ps -> ps.getSource().containsKey("demo.orNegatedExpression"))) + .as("'!profile1 | !profile2' should match when at least one profile is not active") + .isTrue(); + } + @Test public void failToFindNonexistentObject() { Environment env = envRepo.findOne("foo", "bar", null); diff --git a/spring-cloud-config-server/src/test/resources/awss3/application-with-complex-profile-expressions.yaml b/spring-cloud-config-server/src/test/resources/awss3/application-with-complex-profile-expressions.yaml new file mode 100644 index 0000000000..4ded9ab365 --- /dev/null +++ b/spring-cloud-config-server/src/test/resources/awss3/application-with-complex-profile-expressions.yaml @@ -0,0 +1,23 @@ +demo: + base: base-value +--- +spring: + config: + activate: + on-profile: "profile1 & !profile2" +demo: + andExpression: present-when-profile1-active-and-profile2-not-active +--- +spring: + config: + activate: + on-profile: "!(profile1 & profile2)" +demo: + notAndExpression: present-when-not-both-profile1-and-profile2-active +--- +spring: + config: + activate: + on-profile: "!profile1 | !profile2" +demo: + orNegatedExpression: present-when-profile1-or-profile2-not-active diff --git a/spring-cloud-config-server/src/test/resources/awss3/application-with-negated-profile.yaml b/spring-cloud-config-server/src/test/resources/awss3/application-with-negated-profile.yaml new file mode 100644 index 0000000000..2a669cf431 --- /dev/null +++ b/spring-cloud-config-server/src/test/resources/awss3/application-with-negated-profile.yaml @@ -0,0 +1,9 @@ +demo: + base: base-value +--- +spring: + config: + activate: + on-profile: "!my-profile" +demo: + negatedProfileMarker: present-when-my-profile-is-not-active diff --git a/spring-cloud-config-server/src/test/resources/awss3/application-with-two-negated-profiles.yaml b/spring-cloud-config-server/src/test/resources/awss3/application-with-two-negated-profiles.yaml new file mode 100644 index 0000000000..bacb6ee0e1 --- /dev/null +++ b/spring-cloud-config-server/src/test/resources/awss3/application-with-two-negated-profiles.yaml @@ -0,0 +1,16 @@ +demo: + base: base-value +--- +spring: + config: + activate: + on-profile: "!my-profile" +demo: + negatedProfileMarker: present-when-my-profile-is-not-active +--- +spring: + config: + activate: + on-profile: "!other-profile" +demo: + otherNegatedMarker: present-when-other-profile-is-not-active