Skip to content

Commit cfdde5a

Browse files
committed
Support config yaml files embedded in env vars via spring.config.import
1 parent da42305 commit cfdde5a

13 files changed

+227
-15
lines changed

spring-boot-project/spring-boot/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ dependencies {
107107
testImplementation("com.microsoft.sqlserver:mssql-jdbc")
108108
testImplementation("com.mysql:mysql-connector-j")
109109
testImplementation("com.sun.xml.messaging.saaj:saaj-impl")
110+
// TODO: Define strategy for mocking env vars and if/which 3pp to use
111+
testImplementation("uk.org.webcompere:system-stubs-core:2.1.7")
110112
testImplementation("io.projectreactor:reactor-test")
111113
testImplementation("io.r2dbc:r2dbc-h2")
112114
testImplementation("jakarta.inject:jakarta.inject-api")

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessor.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
import org.springframework.core.env.ConfigurableEnvironment;
3333
import org.springframework.core.env.Environment;
3434
import org.springframework.core.io.DefaultResourceLoader;
35+
import org.springframework.core.io.ProtocolResolver;
3536
import org.springframework.core.io.ResourceLoader;
37+
import org.springframework.core.io.support.SpringFactoriesLoader;
3638

3739
/**
3840
* {@link EnvironmentPostProcessor} that loads and applies {@link ConfigData} to Spring's
@@ -92,7 +94,13 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp
9294
void postProcessEnvironment(ConfigurableEnvironment environment, ResourceLoader resourceLoader,
9395
Collection<String> additionalProfiles) {
9496
this.logger.trace("Post-processing environment to add config data");
95-
resourceLoader = (resourceLoader != null) ? resourceLoader : new DefaultResourceLoader();
97+
if (resourceLoader == null) {
98+
DefaultResourceLoader defaultResourceLoader = new DefaultResourceLoader();
99+
SpringFactoriesLoader.forDefaultResourceLocation(defaultResourceLoader.getClassLoader())
100+
.load(ProtocolResolver.class)
101+
.forEach(defaultResourceLoader::addProtocolResolver);
102+
resourceLoader = defaultResourceLoader;
103+
}
96104
getConfigDataEnvironment(environment, resourceLoader, additionalProfiles).processAndApply();
97105
}
98106

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/LocationResourceLoader.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ boolean isPattern(String location) {
7474
Resource getResource(String location) {
7575
validateNonPattern(location);
7676
location = StringUtils.cleanPath(location);
77-
if (!ResourceUtils.isUrl(location)) {
77+
if (!ResourceUtils.isUrl(location) && !location.contains(":")) {
7878
location = ResourceUtils.FILE_URL_PREFIX + location;
7979
}
8080
return this.resourceLoader.getResource(location);

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataReference.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.boot.context.config;
1818

19+
import java.util.Locale;
20+
1921
import org.springframework.boot.env.PropertySourceLoader;
2022
import org.springframework.util.StringUtils;
2123

@@ -51,7 +53,8 @@ class StandardConfigDataReference {
5153
StandardConfigDataReference(ConfigDataLocation configDataLocation, String directory, String root, String profile,
5254
String extension, PropertySourceLoader propertySourceLoader) {
5355
this.configDataLocation = configDataLocation;
54-
String profileSuffix = (StringUtils.hasText(profile)) ? "-" + profile : "";
56+
String profileSuffix = (StringUtils.hasText(profile))
57+
? root.startsWith("env:") ? "_" + profile.toUpperCase(Locale.ROOT) : "-" + profile : "";
5558
this.resourceLocation = root + profileSuffix + ((extension != null) ? "." + extension : "");
5659
this.directory = directory;
5760
this.profile = profile;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.io;
18+
19+
import org.springframework.core.io.ProtocolResolver;
20+
import org.springframework.core.io.Resource;
21+
import org.springframework.core.io.ResourceLoader;
22+
23+
/**
24+
* {@link ProtocolResolver} for resources contained in environment variables.
25+
*
26+
* @author Francisco Bento
27+
*/
28+
class EnvironmentVariableProtocolResolver implements ProtocolResolver {
29+
30+
@Override
31+
public Resource resolve(String location, ResourceLoader resourceLoader) {
32+
return EnvironmentVariableResource.fromUri(location);
33+
}
34+
35+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.io;
18+
19+
import java.io.ByteArrayInputStream;
20+
import java.io.IOException;
21+
import java.io.InputStream;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.Base64;
24+
25+
import org.springframework.core.io.AbstractResource;
26+
import org.springframework.core.io.Resource;
27+
28+
/**
29+
* {@link Resource} implementation for system environment variables.
30+
*
31+
* @author Francisco Bento
32+
* @since 3.5.0
33+
*/
34+
public class EnvironmentVariableResource extends AbstractResource {
35+
36+
/** Pseudo URL prefix for loading from an environment variable: "env:". */
37+
public static final String PSEUDO_URL_PREFIX = "env:";
38+
39+
/** Pseudo URL prefix indicating that the environment variable is base64-encoded. */
40+
public static final String BASE64_ENCODED_PREFIX = "base64:";
41+
42+
private final String envVar;
43+
44+
private final boolean isBase64;
45+
46+
public EnvironmentVariableResource(final String envVar, final boolean isBase64) {
47+
this.envVar = envVar;
48+
this.isBase64 = isBase64;
49+
}
50+
51+
public static EnvironmentVariableResource fromUri(String url) {
52+
if (url.startsWith(PSEUDO_URL_PREFIX)) {
53+
String envVar = url.substring(PSEUDO_URL_PREFIX.length());
54+
boolean isBase64 = false;
55+
if (envVar.startsWith(BASE64_ENCODED_PREFIX)) {
56+
envVar = envVar.substring(BASE64_ENCODED_PREFIX.length());
57+
isBase64 = true;
58+
}
59+
return new EnvironmentVariableResource(envVar, isBase64);
60+
}
61+
return null;
62+
}
63+
64+
@Override
65+
public boolean exists() {
66+
return System.getenv(this.envVar) != null;
67+
}
68+
69+
@Override
70+
public String getDescription() {
71+
return "Environment variable '" + this.envVar + "'";
72+
}
73+
74+
@Override
75+
public InputStream getInputStream() throws IOException {
76+
return new ByteArrayInputStream(getContents());
77+
}
78+
79+
protected byte[] getContents() {
80+
String value = System.getenv(this.envVar);
81+
if (this.isBase64) {
82+
return Base64.getDecoder().decode(value);
83+
}
84+
return value.getBytes(StandardCharsets.UTF_8);
85+
}
86+
87+
}

spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,5 @@ org.springframework.boot.orm.jpa.JpaDependsOnDatabaseInitializationDetector
104104

105105
# Resource Locator Protocol Resolvers
106106
org.springframework.core.io.ProtocolResolver=\
107-
org.springframework.boot.io.Base64ProtocolResolver
107+
org.springframework.boot.io.Base64ProtocolResolver,\
108+
org.springframework.boot.io.EnvironmentVariableProtocolResolver

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
import java.io.FileOutputStream;
2121
import java.io.IOException;
2222
import java.io.OutputStream;
23+
import java.nio.charset.StandardCharsets;
2324
import java.util.ArrayList;
2425
import java.util.Arrays;
26+
import java.util.Base64;
2527
import java.util.Collections;
2628
import java.util.HashMap;
2729
import java.util.LinkedHashMap;
@@ -75,11 +77,11 @@
7577
*/
7678
class ConfigDataEnvironmentPostProcessorIntegrationTests {
7779

78-
private SpringApplication application;
79-
8080
@TempDir
8181
public File temp;
8282

83+
private SpringApplication application;
84+
8385
@BeforeEach
8486
void setup() {
8587
this.application = new SpringApplication(Config.class);
@@ -612,7 +614,8 @@ void runWhenImportFromEarlierDocumentUsesPlaceholder() {
612614
assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("iwasimported");
613615
}
614616

615-
@Test // gh-26858
617+
@Test
618+
// gh-26858
616619
void runWhenImportWithProfileVariantOrdersPropertySourcesCorrectly() {
617620
this.application.setAdditionalProfiles("dev");
618621
ConfigurableApplicationContext context = this.application
@@ -621,6 +624,62 @@ void runWhenImportWithProfileVariantOrdersPropertySourcesCorrectly() {
621624
.isEqualTo("application-import-with-profile-variant-imported-dev");
622625
}
623626

627+
@Test
628+
void runWhenImportYamlFromEnvironmentVariable() throws Exception {
629+
ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs
630+
.withEnvironmentVariable("MY_CONFIG_YAML", """
631+
my:
632+
value: from-env-first-doc
633+
---
634+
my:
635+
value: from-env-second-doc
636+
""")
637+
.execute(() -> this.application
638+
.run("--spring.config.location=classpath:application-import-yaml-from-environment.properties"));
639+
assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("from-env-second-doc");
640+
}
641+
642+
@Test
643+
void runWhenImportYamlFromEnvironmentVariableWithProfileVariant() throws Exception {
644+
this.application.setAdditionalProfiles("dev");
645+
ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs
646+
.withEnvironmentVariables("MY_CONFIG_YAML", """
647+
my:
648+
value: my_config_yaml
649+
""", "MY_CONFIG_YAML_DEV", """
650+
my:
651+
value: my_config_yaml_dev
652+
""")
653+
.execute(() -> this.application.run(
654+
"--spring.config.location=classpath:application-import-yaml-from-environment-with-profile-variant.properties"));
655+
assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("my_config_yaml_dev");
656+
}
657+
658+
@Test
659+
void runWhenImportBase64YamlFromEnvironmentVariable() throws Exception {
660+
ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs
661+
.withEnvironmentVariable("MY_CONFIG_BASE64_YAML", Base64.getEncoder().encodeToString("""
662+
my:
663+
value: from-base64-yaml
664+
""".getBytes(StandardCharsets.UTF_8)))
665+
.execute(() -> this.application
666+
.run("--spring.config.location=classpath:application-import-base64-yaml-from-environment.properties"));
667+
assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("from-base64-yaml");
668+
}
669+
670+
@Test
671+
void runWhenImportPropertiesFromEnvironmentVariable() throws Exception {
672+
ConfigurableApplicationContext context = uk.org.webcompere.systemstubs.SystemStubs
673+
.withEnvironmentVariable("MY_CONFIG_PROPERTIES", """
674+
my.value1: from-properties-1
675+
my.value2: from-properties-2
676+
""")
677+
.execute(() -> this.application
678+
.run("--spring.config.location=classpath:application-import-properties-from-environment.properties"));
679+
assertThat(context.getEnvironment().getProperty("my.value1")).isEqualTo("from-properties-1");
680+
assertThat(context.getEnvironment().getProperty("my.value2")).isEqualTo("from-properties-2");
681+
}
682+
624683
@Test
625684
void runWhenImportWithProfileVariantAndDirectProfileImportOrdersPropertySourcesCorrectly() {
626685
this.application.setAdditionalProfiles("dev");
@@ -637,14 +696,16 @@ void runWhenHasPropertyInProfileDocumentThrowsException() {
637696
.withCauseInstanceOf(InactiveConfigDataAccessException.class);
638697
}
639698

640-
@Test // gh-29386
699+
@Test
700+
// gh-29386
641701
void runWhenHasPropertyInEarlierProfileDocumentThrowsException() {
642702
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run(
643703
"--spring.config.location=classpath:application-import-with-placeholder-in-earlier-profile-document.properties"))
644704
.withCauseInstanceOf(InactiveConfigDataAccessException.class);
645705
}
646706

647-
@Test // gh-29386
707+
@Test
708+
// gh-29386
648709
void runWhenHasPropertyInEarlierDocumentLoads() {
649710
ConfigurableApplicationContext context = this.application.run(
650711
"--spring.config.location=classpath:application-import-with-placeholder-in-earlier-document.properties");
@@ -769,7 +830,8 @@ void runWhenOptionalWildcardLocationHasNoSubdirectoriesDoesNotThrow() {
769830
.run("--spring.config.location=optional:file:src/test/resources/config/0-empty/*/"));
770831
}
771832

772-
@Test // gh-24990
833+
@Test
834+
// gh-24990
773835
void runWhenHasProfileSpecificFileWithActiveOnProfileProperty() {
774836
ConfigurableApplicationContext context = this.application
775837
.run("--spring.config.name=application-activate-on-profile-in-profile-specific-file");
@@ -778,7 +840,8 @@ void runWhenHasProfileSpecificFileWithActiveOnProfileProperty() {
778840
assertThat(environment.getProperty("test2")).isEqualTo("test2");
779841
}
780842

781-
@Test // gh-26960
843+
@Test
844+
// gh-26960
782845
void runWhenHasProfileSpecificImportWithImportImportsSecondProfileSpecificFile() {
783846
ConfigurableApplicationContext context = this.application
784847
.run("--spring.config.name=application-profile-specific-import-with-import");
@@ -791,7 +854,8 @@ void runWhenHasProfileSpecificImportWithImportImportsSecondProfileSpecificFile()
791854
assertThat(environment.containsProperty("application-profile-specific-import-with-import-import-p2")).isTrue();
792855
}
793856

794-
@Test // gh-26960
857+
@Test
858+
// gh-26960
795859
void runWhenHasProfileSpecificImportWithCustomImportResolvesProfileSpecific() {
796860
ConfigurableApplicationContext context = this.application
797861
.run("--spring.config.name=application-profile-specific-import-with-custom-import");
@@ -800,7 +864,8 @@ void runWhenHasProfileSpecificImportWithCustomImportResolvesProfileSpecific() {
800864
assertThat(environment.containsProperty("test:boot:ps")).isTrue();
801865
}
802866

803-
@Test // gh-26593
867+
@Test
868+
// gh-26593
804869
void runWhenHasFilesInRootAndConfigWithProfiles() {
805870
ConfigurableApplicationContext context = this.application
806871
.run("--spring.config.name=file-in-root-and-config-with-profile", "--spring.profiles.active=p1,p2");

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/io/ProtocolResolverApplicationContextInitializerTests.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ void initializeAddsProtocolResolversToApplicationContext() {
4040
ProtocolResolverApplicationContextInitializer initializer = new ProtocolResolverApplicationContextInitializer();
4141
initializer.initialize(context);
4242
assertThat(context).isInstanceOf(DefaultResourceLoader.class);
43-
Collection<ProtocolResolver> protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers();
44-
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class);
43+
Collection<ProtocolResolver>
44+
45+
protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers();
46+
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class,
47+
EnvironmentVariableProtocolResolver.class);
4548
}
4649
}
4750

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
my.value=application-import-base64-yaml-from-environment
2+
spring.config.import=env:base64:MY_CONFIG_BASE64_YAML[.yaml]

0 commit comments

Comments
 (0)