Skip to content

Commit 61d7f37

Browse files
committed
Implement config data loader to load from environment variables
The config data loader supports the env: prefix and also accepts extension hints. Example: env:VAR1[.properties] reads the environment variable 'VAR1' in properties format (using the PropertiesPropertySourceLoader). The PropertySourceLoaders are loaded via spring.factories. Also adds a smoke test to test it end to end. Closes gh-41609
1 parent 910d57e commit 61d7f37

File tree

13 files changed

+671
-2
lines changed

13 files changed

+671
-2
lines changed

spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,39 @@ spring:
354354

355355

356356

357+
[[features.external-config.files.env-variables]]
358+
=== Using Environment Variables
359+
360+
When running applications on a cloud platform (such as Kubernetes) you often need to read config values that the platform supplies.
361+
You can either use environment variables for such purpose, or you can use xref:reference:features/external-config.adoc#features.external-config.files.configtree[configuration trees].
362+
363+
You can even store whole configurations in properties or yaml format in (multiline) environment variables and load them using the `env:` prefix.
364+
Assume there's an environment variable called `MY_CONFIGURATION` with this content:
365+
366+
[source,properties]
367+
----
368+
my.name=Service1
369+
my.cluster=Cluster1
370+
----
371+
372+
Using the `env:` prefix it is possible to import all properties from this variable:
373+
374+
[configprops,yaml]
375+
----
376+
spring:
377+
config:
378+
import: "env:MY_CONFIGURATION"
379+
----
380+
381+
TIP: This feature also supports xref:reference:features/external-config.adoc#features.external-config.files.importing-extensionless[specifying the extension].
382+
The default extension is `.properties`.
383+
384+
385+
357386
[[features.external-config.files.configtree]]
358387
=== Using Configuration Trees
359388

360-
When running applications on a cloud platform (such as Kubernetes) you often need to read config values that the platform supplies.
361-
It is not uncommon to use environment variables for such purposes, but this can have drawbacks, especially if the value is supposed to be kept secret.
389+
Storing config values in environment variables has drawbacks, especially if the value is supposed to be kept secret.
362390

363391
As an alternative to environment variables, many cloud platforms now allow you to map configuration into mounted data volumes.
364392
For example, Kubernetes can volume mount both https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#populate-a-volume-with-data-stored-in-a-configmap[`ConfigMaps`] and https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-from-a-pod[`Secrets`].
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.context.config;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.function.Function;
22+
23+
import org.springframework.core.io.ByteArrayResource;
24+
25+
/**
26+
* {@link ConfigDataLoader} to load data from environment variables.
27+
*
28+
* @author Moritz Halbritter
29+
*/
30+
class EnvConfigDataLoader implements ConfigDataLoader<EnvConfigDataResource> {
31+
32+
private final Function<String, String> readEnvVariable;
33+
34+
EnvConfigDataLoader() {
35+
this.readEnvVariable = System::getenv;
36+
}
37+
38+
EnvConfigDataLoader(Function<String, String> readEnvVariable) {
39+
this.readEnvVariable = readEnvVariable;
40+
}
41+
42+
@Override
43+
public ConfigData load(ConfigDataLoaderContext context, EnvConfigDataResource resource)
44+
throws IOException, ConfigDataResourceNotFoundException {
45+
String content = this.readEnvVariable.apply(resource.getVariableName());
46+
if (content == null) {
47+
throw new ConfigDataResourceNotFoundException(resource);
48+
}
49+
String name = String.format("Environment variable '%s' via location '%s'", resource.getVariableName(),
50+
resource.getLocation());
51+
return new ConfigData(resource.getLoader().load(name, createResource(content)));
52+
}
53+
54+
private ByteArrayResource createResource(String content) {
55+
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8));
56+
}
57+
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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.context.config;
18+
19+
import java.util.Collections;
20+
import java.util.List;
21+
import java.util.function.Function;
22+
import java.util.regex.Matcher;
23+
import java.util.regex.Pattern;
24+
25+
import org.springframework.boot.env.PropertySourceLoader;
26+
import org.springframework.core.io.support.SpringFactoriesLoader;
27+
28+
/**
29+
* {@link ConfigDataLocationResolver} to resolve {@code env:} locations.
30+
*
31+
* @author Moritz Halbritter
32+
*/
33+
class EnvConfigDataLocationResolver implements ConfigDataLocationResolver<EnvConfigDataResource> {
34+
35+
private static final String PREFIX = "env:";
36+
37+
private static final Pattern EXTENSION_HINT_PATTERN = Pattern.compile("^(.*)\\[(\\.\\w+)](?!\\[)$");
38+
39+
private static final String DEFAULT_EXTENSION = ".properties";
40+
41+
private final List<PropertySourceLoader> loaders;
42+
43+
private final Function<String, String> readEnvVariable;
44+
45+
EnvConfigDataLocationResolver() {
46+
this.loaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, getClass().getClassLoader());
47+
this.readEnvVariable = System::getenv;
48+
}
49+
50+
EnvConfigDataLocationResolver(List<PropertySourceLoader> loaders, Function<String, String> readEnvVariable) {
51+
this.loaders = loaders;
52+
this.readEnvVariable = readEnvVariable;
53+
}
54+
55+
@Override
56+
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
57+
return location.hasPrefix(PREFIX);
58+
}
59+
60+
@Override
61+
public List<EnvConfigDataResource> resolve(ConfigDataLocationResolverContext context, ConfigDataLocation location)
62+
throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException {
63+
String value = location.getNonPrefixedValue(PREFIX);
64+
Matcher matcher = EXTENSION_HINT_PATTERN.matcher(value);
65+
String extension = getExtension(matcher);
66+
String variableName = getVariableName(matcher, value);
67+
PropertySourceLoader loader = getLoader(extension);
68+
if (hasEnvVariable(variableName)) {
69+
return List.of(new EnvConfigDataResource(location, variableName, loader));
70+
}
71+
if (location.isOptional()) {
72+
return Collections.emptyList();
73+
}
74+
throw new ConfigDataLocationNotFoundException(location,
75+
"Environment variable '%s' is not set".formatted(variableName), null);
76+
}
77+
78+
private PropertySourceLoader getLoader(String extension) {
79+
if (extension == null) {
80+
extension = DEFAULT_EXTENSION;
81+
}
82+
if (extension.startsWith(".")) {
83+
extension = extension.substring(1);
84+
}
85+
for (PropertySourceLoader loader : this.loaders) {
86+
for (String supportedExtension : loader.getFileExtensions()) {
87+
if (supportedExtension.equalsIgnoreCase(extension)) {
88+
return loader;
89+
}
90+
}
91+
}
92+
throw new IllegalStateException(
93+
"File extension '%s' is not known to any PropertySourceLoader".formatted(extension));
94+
}
95+
96+
private boolean hasEnvVariable(String variableName) {
97+
return this.readEnvVariable.apply(variableName) != null;
98+
}
99+
100+
private String getVariableName(Matcher matcher, String value) {
101+
if (matcher.matches()) {
102+
return matcher.group(1);
103+
}
104+
return value;
105+
}
106+
107+
private String getExtension(Matcher matcher) {
108+
if (matcher.matches()) {
109+
return matcher.group(2);
110+
}
111+
return null;
112+
}
113+
114+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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.context.config;
18+
19+
import java.util.Objects;
20+
21+
import org.springframework.boot.env.PropertySourceLoader;
22+
23+
/**
24+
* {@link ConfigDataResource} used by {@link EnvConfigDataLoader}.
25+
*
26+
* @author Moritz Halbritter
27+
*/
28+
class EnvConfigDataResource extends ConfigDataResource {
29+
30+
private final ConfigDataLocation location;
31+
32+
private final String variableName;
33+
34+
private final PropertySourceLoader loader;
35+
36+
EnvConfigDataResource(ConfigDataLocation location, String variableName, PropertySourceLoader loader) {
37+
super(location.isOptional());
38+
this.location = location;
39+
this.variableName = variableName;
40+
this.loader = loader;
41+
}
42+
43+
ConfigDataLocation getLocation() {
44+
return this.location;
45+
}
46+
47+
String getVariableName() {
48+
return this.variableName;
49+
}
50+
51+
PropertySourceLoader getLoader() {
52+
return this.loader;
53+
}
54+
55+
@Override
56+
public boolean equals(Object o) {
57+
if (o == null || getClass() != o.getClass()) {
58+
return false;
59+
}
60+
EnvConfigDataResource that = (EnvConfigDataResource) o;
61+
return Objects.equals(this.location, that.location) && Objects.equals(this.variableName, that.variableName)
62+
&& Objects.equals(this.loader, that.loader);
63+
}
64+
65+
@Override
66+
public int hashCode() {
67+
return Objects.hash(this.location, this.variableName, this.loader);
68+
}
69+
70+
@Override
71+
public String toString() {
72+
return "env variable [" + this.variableName + "]";
73+
}
74+
75+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ org.springframework.boot.env.YamlPropertySourceLoader
1212
# ConfigData Location Resolvers
1313
org.springframework.boot.context.config.ConfigDataLocationResolver=\
1414
org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver,\
15+
org.springframework.boot.context.config.EnvConfigDataLocationResolver,\
1516
org.springframework.boot.context.config.StandardConfigDataLocationResolver
1617

1718
# ConfigData Loaders
1819
org.springframework.boot.context.config.ConfigDataLoader=\
1920
org.springframework.boot.context.config.ConfigTreeConfigDataLoader,\
21+
org.springframework.boot.context.config.EnvConfigDataLoader,\
2022
org.springframework.boot.context.config.StandardConfigDataLoader
2123

2224
# Application Context Factories
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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.context.config;
18+
19+
import java.io.IOException;
20+
import java.util.HashMap;
21+
import java.util.Map;
22+
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.boot.env.PropertiesPropertySourceLoader;
27+
import org.springframework.core.env.PropertySource;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
31+
import static org.mockito.Mockito.mock;
32+
33+
/**
34+
* Tests for {@link EnvConfigDataLoader}.
35+
*
36+
* @author Moritz Halbritter
37+
*/
38+
class EnvConfigDataLoaderTests {
39+
40+
private ConfigDataLoaderContext context;
41+
42+
private Map<String, String> envVariables;
43+
44+
private EnvConfigDataLoader loader;
45+
46+
@BeforeEach
47+
void setUp() {
48+
this.context = mock(ConfigDataLoaderContext.class);
49+
this.envVariables = new HashMap<>();
50+
this.loader = new EnvConfigDataLoader(this.envVariables::get);
51+
}
52+
53+
@Test
54+
void shouldLoadFromVariable() throws IOException {
55+
this.envVariables.put("VAR1", "key1=value1");
56+
ConfigData data = this.loader.load(this.context, createResource("VAR1"));
57+
assertThat(data.getPropertySources()).hasSize(1);
58+
PropertySource<?> propertySource = data.getPropertySources().get(0);
59+
assertThat(propertySource.getProperty("key1")).isEqualTo("value1");
60+
}
61+
62+
@Test
63+
void shouldFailIfVariableIsNotSet() {
64+
assertThatExceptionOfType(ConfigDataResourceNotFoundException.class)
65+
.isThrownBy(() -> this.loader.load(this.context, createResource("VAR1")))
66+
.withMessage("Config data resource 'env variable [VAR1]' cannot be found");
67+
}
68+
69+
private static EnvConfigDataResource createResource(String variableName) {
70+
return new EnvConfigDataResource(ConfigDataLocation.of("env:" + variableName), variableName,
71+
new PropertiesPropertySourceLoader());
72+
}
73+
74+
}

0 commit comments

Comments
 (0)