diff --git a/spring-boot-project/spring-boot/build.gradle b/spring-boot-project/spring-boot/build.gradle index 6d8fac07ede6..d1d33ccdc68d 100644 --- a/spring-boot-project/spring-boot/build.gradle +++ b/spring-boot-project/spring-boot/build.gradle @@ -107,6 +107,8 @@ dependencies { testImplementation("com.microsoft.sqlserver:mssql-jdbc") testImplementation("com.mysql:mysql-connector-j") testImplementation("com.sun.xml.messaging.saaj:saaj-impl") + // TODO: Define strategy for mocking env vars and if/which 3pp to use + testImplementation("uk.org.webcompere:system-stubs-core:2.1.7") testImplementation("io.projectreactor:reactor-test") testImplementation("io.r2dbc:r2dbc-h2") testImplementation("jakarta.inject:jakarta.inject-api") diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.java index 5825c9dfa4f7..1355750ec1d7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.java @@ -32,7 +32,9 @@ import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ProtocolResolver; import org.springframework.core.io.ResourceLoader; +import org.springframework.core.io.support.SpringFactoriesLoader; /** * {@link EnvironmentPostProcessor} that loads and applies {@link ConfigData} to Spring's @@ -92,7 +94,13 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp void postProcessEnvironment(ConfigurableEnvironment environment, ResourceLoader resourceLoader, Collection additionalProfiles) { this.logger.trace("Post-processing environment to add config data"); - resourceLoader = (resourceLoader != null) ? resourceLoader : new DefaultResourceLoader(); + if (resourceLoader == null) { + DefaultResourceLoader defaultResourceLoader = new DefaultResourceLoader(); + SpringFactoriesLoader.forDefaultResourceLocation(defaultResourceLoader.getClassLoader()) + .load(ProtocolResolver.class) + .forEach(defaultResourceLoader::addProtocolResolver); + resourceLoader = defaultResourceLoader; + } getConfigDataEnvironment(environment, resourceLoader, additionalProfiles).processAndApply(); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/LocationResourceLoader.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/LocationResourceLoader.java index 719ae3d72f18..0dfec7070b15 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/LocationResourceLoader.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/LocationResourceLoader.java @@ -74,7 +74,7 @@ boolean isPattern(String location) { Resource getResource(String location) { validateNonPattern(location); location = StringUtils.cleanPath(location); - if (!ResourceUtils.isUrl(location)) { + if (!ResourceUtils.isUrl(location) && !location.contains(":")) { location = ResourceUtils.FILE_URL_PREFIX + location; } return this.resourceLoader.getResource(location); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataReference.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataReference.java index 7e50bcf72fc7..240325b10884 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataReference.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataReference.java @@ -16,6 +16,8 @@ package org.springframework.boot.context.config; +import java.util.Locale; + import org.springframework.boot.env.PropertySourceLoader; import org.springframework.util.StringUtils; @@ -51,7 +53,8 @@ class StandardConfigDataReference { StandardConfigDataReference(ConfigDataLocation configDataLocation, String directory, String root, String profile, String extension, PropertySourceLoader propertySourceLoader) { this.configDataLocation = configDataLocation; - String profileSuffix = (StringUtils.hasText(profile)) ? "-" + profile : ""; + String profileSuffix = (StringUtils.hasText(profile)) + ? root.startsWith("env:") ? "_" + profile.toUpperCase(Locale.ROOT) : "-" + profile : ""; this.resourceLocation = root + profileSuffix + ((extension != null) ? "." + extension : ""); this.directory = directory; this.profile = profile; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableProtocolResolver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableProtocolResolver.java new file mode 100644 index 000000000000..fcae4c70da5b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableProtocolResolver.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.io; + +import org.springframework.core.io.ProtocolResolver; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * {@link ProtocolResolver} for resources contained in environment variables. + * + * @author Francisco Bento + */ +class EnvironmentVariableProtocolResolver implements ProtocolResolver { + + @Override + public Resource resolve(String location, ResourceLoader resourceLoader) { + return EnvironmentVariableResource.fromUri(location); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableResource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableResource.java new file mode 100644 index 000000000000..23ddf1595f70 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/io/EnvironmentVariableResource.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.io; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; + +/** + * {@link Resource} implementation for system environment variables. + * + * @author Francisco Bento + * @since 3.5.0 + */ +public class EnvironmentVariableResource extends AbstractResource { + + /** Pseudo URL prefix for loading from an environment variable: "env:". */ + public static final String PSEUDO_URL_PREFIX = "env:"; + + /** Pseudo URL prefix indicating that the environment variable is base64-encoded. */ + public static final String BASE64_ENCODED_PREFIX = "base64:"; + + private final String envVar; + + private final boolean isBase64; + + public EnvironmentVariableResource(final String envVar, final boolean isBase64) { + this.envVar = envVar; + this.isBase64 = isBase64; + } + + public static EnvironmentVariableResource fromUri(String url) { + if (url.startsWith(PSEUDO_URL_PREFIX)) { + String envVar = url.substring(PSEUDO_URL_PREFIX.length()); + boolean isBase64 = false; + if (envVar.startsWith(BASE64_ENCODED_PREFIX)) { + envVar = envVar.substring(BASE64_ENCODED_PREFIX.length()); + isBase64 = true; + } + return new EnvironmentVariableResource(envVar, isBase64); + } + return null; + } + + @Override + public boolean exists() { + return System.getenv(this.envVar) != null; + } + + @Override + public String getDescription() { + return "Environment variable '" + this.envVar + "'"; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(getContents()); + } + + protected byte[] getContents() { + String value = System.getenv(this.envVar); + if (this.isBase64) { + return Base64.getDecoder().decode(value); + } + return value.getBytes(StandardCharsets.UTF_8); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories index 194863fb9e0f..7ecdc1105073 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories @@ -104,4 +104,5 @@ org.springframework.boot.orm.jpa.JpaDependsOnDatabaseInitializationDetector # Resource Locator Protocol Resolvers org.springframework.core.io.ProtocolResolver=\ -org.springframework.boot.io.Base64ProtocolResolver +org.springframework.boot.io.Base64ProtocolResolver,\ +org.springframework.boot.io.EnvironmentVariableProtocolResolver diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java index 940db092ec43..8dd6ad173c61 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java @@ -20,8 +20,10 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -621,6 +623,62 @@ void runWhenImportWithProfileVariantOrdersPropertySourcesCorrectly() { .isEqualTo("application-import-with-profile-variant-imported-dev"); } + @Test + void runWhenImportYamlFromEnvironmentVariable() throws Exception { + ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs + .withEnvironmentVariable("MY_CONFIG_YAML", """ + my: + value: from-env-first-doc + --- + my: + value: from-env-second-doc + """) + .execute(() -> this.application + .run("--spring.config.location=classpath:application-import-yaml-from-environment.properties")); + assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("from-env-second-doc"); + } + + @Test + void runWhenImportYamlFromEnvironmentVariableWithProfileVariant() throws Exception { + this.application.setAdditionalProfiles("dev"); + ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs + .withEnvironmentVariables("MY_CONFIG_YAML", """ + my: + value: my_config_yaml + """, "MY_CONFIG_YAML_DEV", """ + my: + value: my_config_yaml_dev + """) + .execute(() -> this.application.run( + "--spring.config.location=classpath:application-import-yaml-from-environment-with-profile-variant.properties")); + assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("my_config_yaml_dev"); + } + + @Test + void runWhenImportBase64YamlFromEnvironmentVariable() throws Exception { + ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs + .withEnvironmentVariable("MY_CONFIG_BASE64_YAML", Base64.getEncoder().encodeToString(""" + my: + value: from-base64-yaml + """.getBytes(StandardCharsets.UTF_8))) + .execute(() -> this.application + .run("--spring.config.location=classpath:application-import-base64-yaml-from-environment.properties")); + assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("from-base64-yaml"); + } + + @Test + void runWhenImportPropertiesFromEnvironmentVariable() throws Exception { + ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs + .withEnvironmentVariable("MY_CONFIG_PROPERTIES", """ + my.value1: from-properties-1 + my.value2: from-properties-2 + """) + .execute(() -> this.application + .run("--spring.config.location=classpath:application-import-properties-from-environment.properties")); + assertThat(context.getEnvironment().getProperty("my.value1")).isEqualTo("from-properties-1"); + assertThat(context.getEnvironment().getProperty("my.value2")).isEqualTo("from-properties-2"); + } + @Test void runWhenImportWithProfileVariantAndDirectProfileImportOrdersPropertySourcesCorrectly() { this.application.setAdditionalProfiles("dev"); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/ProtocolResolverApplicationContextInitializerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/ProtocolResolverApplicationContextInitializerTests.java index b7d8bad54a55..3477f2e0c7e7 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/ProtocolResolverApplicationContextInitializerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/ProtocolResolverApplicationContextInitializerTests.java @@ -41,7 +41,8 @@ void initializeAddsProtocolResolversToApplicationContext() { initializer.initialize(context); assertThat(context).isInstanceOf(DefaultResourceLoader.class); Collection protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers(); - assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class); + assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class, + EnvironmentVariableProtocolResolver.class); } } diff --git a/spring-boot-project/spring-boot/src/test/resources/application-import-base64-yaml-from-environment.properties b/spring-boot-project/spring-boot/src/test/resources/application-import-base64-yaml-from-environment.properties new file mode 100644 index 000000000000..60c06eed31c6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/application-import-base64-yaml-from-environment.properties @@ -0,0 +1,2 @@ +my.value=application-import-base64-yaml-from-environment +spring.config.import=env:base64:MY_CONFIG_BASE64_YAML[.yaml] diff --git a/spring-boot-project/spring-boot/src/test/resources/application-import-properties-from-environment.properties b/spring-boot-project/spring-boot/src/test/resources/application-import-properties-from-environment.properties new file mode 100644 index 000000000000..07aa71b73143 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/application-import-properties-from-environment.properties @@ -0,0 +1,2 @@ +my.value=application-import-properties-from-environment +spring.config.import=env:MY_CONFIG_PROPERTIES[.properties] diff --git a/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment-with-profile-variant.properties b/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment-with-profile-variant.properties new file mode 100644 index 000000000000..155996f5962e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment-with-profile-variant.properties @@ -0,0 +1,2 @@ +spring.config.import=env:MY_CONFIG_YAML[.yaml] +my.value=application-import-from-environment-with-profile-variant diff --git a/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment.properties b/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment.properties new file mode 100644 index 000000000000..92c68842df18 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/application-import-yaml-from-environment.properties @@ -0,0 +1,2 @@ +my.value=application-import-yaml-from-environment +spring.config.import=env:MY_CONFIG_YAML[.yaml]