Skip to content

Commit c7ce726

Browse files
committed
Support config yaml files embedded in env vars via spring.config.import
1 parent 51d15c7 commit c7ce726

13 files changed

+210
-7
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: 60 additions & 2 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);
@@ -621,6 +623,62 @@ void runWhenImportWithProfileVariantOrdersPropertySourcesCorrectly() {
621623
.isEqualTo("application-import-with-profile-variant-imported-dev");
622624
}
623625

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

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ void initializeAddsProtocolResolversToApplicationContext() {
4141
initializer.initialize(context);
4242
assertThat(context).isInstanceOf(DefaultResourceLoader.class);
4343
Collection<ProtocolResolver> protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers();
44-
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class);
44+
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class,
45+
EnvironmentVariableProtocolResolver.class);
4546
}
4647
}
4748

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)