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
This commit is contained in:
Moritz Halbritter 2025-01-30 12:04:05 +01:00
parent 910d57ed90
commit 61d7f3783e
13 changed files with 671 additions and 2 deletions

View File

@ -354,11 +354,39 @@ spring:
[[features.external-config.files.env-variables]]
=== Using Environment Variables
When running applications on a cloud platform (such as Kubernetes) you often need to read config values that the platform supplies.
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].
You can even store whole configurations in properties or yaml format in (multiline) environment variables and load them using the `env:` prefix.
Assume there's an environment variable called `MY_CONFIGURATION` with this content:
[source,properties]
----
my.name=Service1
my.cluster=Cluster1
----
Using the `env:` prefix it is possible to import all properties from this variable:
[configprops,yaml]
----
spring:
config:
import: "env:MY_CONFIGURATION"
----
TIP: This feature also supports xref:reference:features/external-config.adoc#features.external-config.files.importing-extensionless[specifying the extension].
The default extension is `.properties`.
[[features.external-config.files.configtree]]
=== Using Configuration Trees
When running applications on a cloud platform (such as Kubernetes) you often need to read config values that the platform supplies.
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.
Storing config values in environment variables has drawbacks, especially if the value is supposed to be kept secret.
As an alternative to environment variables, many cloud platforms now allow you to map configuration into mounted data volumes.
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`].

View File

@ -0,0 +1,58 @@
/*
* 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.context.config;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;
import org.springframework.core.io.ByteArrayResource;
/**
* {@link ConfigDataLoader} to load data from environment variables.
*
* @author Moritz Halbritter
*/
class EnvConfigDataLoader implements ConfigDataLoader<EnvConfigDataResource> {
private final Function<String, String> readEnvVariable;
EnvConfigDataLoader() {
this.readEnvVariable = System::getenv;
}
EnvConfigDataLoader(Function<String, String> readEnvVariable) {
this.readEnvVariable = readEnvVariable;
}
@Override
public ConfigData load(ConfigDataLoaderContext context, EnvConfigDataResource resource)
throws IOException, ConfigDataResourceNotFoundException {
String content = this.readEnvVariable.apply(resource.getVariableName());
if (content == null) {
throw new ConfigDataResourceNotFoundException(resource);
}
String name = String.format("Environment variable '%s' via location '%s'", resource.getVariableName(),
resource.getLocation());
return new ConfigData(resource.getLoader().load(name, createResource(content)));
}
private ByteArrayResource createResource(String content) {
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8));
}
}

View File

@ -0,0 +1,114 @@
/*
* 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.context.config;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.boot.env.PropertySourceLoader;
import org.springframework.core.io.support.SpringFactoriesLoader;
/**
* {@link ConfigDataLocationResolver} to resolve {@code env:} locations.
*
* @author Moritz Halbritter
*/
class EnvConfigDataLocationResolver implements ConfigDataLocationResolver<EnvConfigDataResource> {
private static final String PREFIX = "env:";
private static final Pattern EXTENSION_HINT_PATTERN = Pattern.compile("^(.*)\\[(\\.\\w+)](?!\\[)$");
private static final String DEFAULT_EXTENSION = ".properties";
private final List<PropertySourceLoader> loaders;
private final Function<String, String> readEnvVariable;
EnvConfigDataLocationResolver() {
this.loaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, getClass().getClassLoader());
this.readEnvVariable = System::getenv;
}
EnvConfigDataLocationResolver(List<PropertySourceLoader> loaders, Function<String, String> readEnvVariable) {
this.loaders = loaders;
this.readEnvVariable = readEnvVariable;
}
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return location.hasPrefix(PREFIX);
}
@Override
public List<EnvConfigDataResource> resolve(ConfigDataLocationResolverContext context, ConfigDataLocation location)
throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException {
String value = location.getNonPrefixedValue(PREFIX);
Matcher matcher = EXTENSION_HINT_PATTERN.matcher(value);
String extension = getExtension(matcher);
String variableName = getVariableName(matcher, value);
PropertySourceLoader loader = getLoader(extension);
if (hasEnvVariable(variableName)) {
return List.of(new EnvConfigDataResource(location, variableName, loader));
}
if (location.isOptional()) {
return Collections.emptyList();
}
throw new ConfigDataLocationNotFoundException(location,
"Environment variable '%s' is not set".formatted(variableName), null);
}
private PropertySourceLoader getLoader(String extension) {
if (extension == null) {
extension = DEFAULT_EXTENSION;
}
if (extension.startsWith(".")) {
extension = extension.substring(1);
}
for (PropertySourceLoader loader : this.loaders) {
for (String supportedExtension : loader.getFileExtensions()) {
if (supportedExtension.equalsIgnoreCase(extension)) {
return loader;
}
}
}
throw new IllegalStateException(
"File extension '%s' is not known to any PropertySourceLoader".formatted(extension));
}
private boolean hasEnvVariable(String variableName) {
return this.readEnvVariable.apply(variableName) != null;
}
private String getVariableName(Matcher matcher, String value) {
if (matcher.matches()) {
return matcher.group(1);
}
return value;
}
private String getExtension(Matcher matcher) {
if (matcher.matches()) {
return matcher.group(2);
}
return null;
}
}

View File

@ -0,0 +1,75 @@
/*
* 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.context.config;
import java.util.Objects;
import org.springframework.boot.env.PropertySourceLoader;
/**
* {@link ConfigDataResource} used by {@link EnvConfigDataLoader}.
*
* @author Moritz Halbritter
*/
class EnvConfigDataResource extends ConfigDataResource {
private final ConfigDataLocation location;
private final String variableName;
private final PropertySourceLoader loader;
EnvConfigDataResource(ConfigDataLocation location, String variableName, PropertySourceLoader loader) {
super(location.isOptional());
this.location = location;
this.variableName = variableName;
this.loader = loader;
}
ConfigDataLocation getLocation() {
return this.location;
}
String getVariableName() {
return this.variableName;
}
PropertySourceLoader getLoader() {
return this.loader;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) {
return false;
}
EnvConfigDataResource that = (EnvConfigDataResource) o;
return Objects.equals(this.location, that.location) && Objects.equals(this.variableName, that.variableName)
&& Objects.equals(this.loader, that.loader);
}
@Override
public int hashCode() {
return Objects.hash(this.location, this.variableName, this.loader);
}
@Override
public String toString() {
return "env variable [" + this.variableName + "]";
}
}

View File

@ -12,11 +12,13 @@ org.springframework.boot.env.YamlPropertySourceLoader
# ConfigData Location Resolvers
org.springframework.boot.context.config.ConfigDataLocationResolver=\
org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver,\
org.springframework.boot.context.config.EnvConfigDataLocationResolver,\
org.springframework.boot.context.config.StandardConfigDataLocationResolver
# ConfigData Loaders
org.springframework.boot.context.config.ConfigDataLoader=\
org.springframework.boot.context.config.ConfigTreeConfigDataLoader,\
org.springframework.boot.context.config.EnvConfigDataLoader,\
org.springframework.boot.context.config.StandardConfigDataLoader
# Application Context Factories

View File

@ -0,0 +1,74 @@
/*
* 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.context.config;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.PropertiesPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link EnvConfigDataLoader}.
*
* @author Moritz Halbritter
*/
class EnvConfigDataLoaderTests {
private ConfigDataLoaderContext context;
private Map<String, String> envVariables;
private EnvConfigDataLoader loader;
@BeforeEach
void setUp() {
this.context = mock(ConfigDataLoaderContext.class);
this.envVariables = new HashMap<>();
this.loader = new EnvConfigDataLoader(this.envVariables::get);
}
@Test
void shouldLoadFromVariable() throws IOException {
this.envVariables.put("VAR1", "key1=value1");
ConfigData data = this.loader.load(this.context, createResource("VAR1"));
assertThat(data.getPropertySources()).hasSize(1);
PropertySource<?> propertySource = data.getPropertySources().get(0);
assertThat(propertySource.getProperty("key1")).isEqualTo("value1");
}
@Test
void shouldFailIfVariableIsNotSet() {
assertThatExceptionOfType(ConfigDataResourceNotFoundException.class)
.isThrownBy(() -> this.loader.load(this.context, createResource("VAR1")))
.withMessage("Config data resource 'env variable [VAR1]' cannot be found");
}
private static EnvConfigDataResource createResource(String variableName) {
return new EnvConfigDataResource(ConfigDataLocation.of("env:" + variableName), variableName,
new PropertiesPropertySourceLoader());
}
}

View File

@ -0,0 +1,131 @@
/*
* 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.context.config;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.PropertiesPropertySourceLoader;
import org.springframework.boot.env.YamlPropertySourceLoader;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link EnvConfigDataLocationResolver}.
*
* @author Moritz Halbritter
*/
class EnvConfigDataLocationResolverTests {
private EnvConfigDataLocationResolver resolver;
private Map<String, String> envVariables;
private ConfigDataLocationResolverContext context;
@BeforeEach
void setUp() {
this.context = mock(ConfigDataLocationResolverContext.class);
this.envVariables = new HashMap<>();
this.resolver = new EnvConfigDataLocationResolver(
List.of(new PropertiesPropertySourceLoader(), new YamlPropertySourceLoader()), this.envVariables::get);
}
@Test
void isResolvable() {
assertThat(this.resolver.isResolvable(this.context, ConfigDataLocation.of("env:VAR1"))).isTrue();
assertThat(this.resolver.isResolvable(this.context, ConfigDataLocation.of("dummy:VAR1"))).isFalse();
assertThat(this.resolver.isResolvable(this.context, ConfigDataLocation.of("VAR1"))).isFalse();
}
@Test
void shouldResolve() {
this.envVariables.put("VAR1", "VALUE1");
ConfigDataLocation location = ConfigDataLocation.of("env:VAR1");
List<EnvConfigDataResource> resolved = this.resolver.resolve(this.context, location);
assertThat(resolved).hasSize(1);
EnvConfigDataResource resource = resolved.get(0);
assertThat(resource.getLocation()).isEqualTo(location);
assertThat(resource.getVariableName()).isEqualTo("VAR1");
assertThat(resource.getLoader()).isInstanceOf(PropertiesPropertySourceLoader.class);
}
@Test
void shouldResolveOptional() {
this.envVariables.put("VAR1", "VALUE1");
ConfigDataLocation location = ConfigDataLocation.of("optional:env:VAR1");
List<EnvConfigDataResource> resolved = this.resolver.resolve(this.context, location);
assertThat(resolved).hasSize(1);
EnvConfigDataResource resource = resolved.get(0);
assertThat(resource.getLocation()).isEqualTo(location);
assertThat(resource.getVariableName()).isEqualTo("VAR1");
assertThat(resource.getLoader()).isInstanceOf(PropertiesPropertySourceLoader.class);
}
@Test
void shouldResolveOptionalIfVariableIsNotSet() {
ConfigDataLocation location = ConfigDataLocation.of("optional:env:VAR1");
List<EnvConfigDataResource> resolved = this.resolver.resolve(this.context, location);
assertThat(resolved).isEmpty();
}
@Test
void shouldResolveWithPropertiesExtension() {
this.envVariables.put("VAR1", "VALUE1");
ConfigDataLocation location = ConfigDataLocation.of("env:VAR1[.properties]");
List<EnvConfigDataResource> resolved = this.resolver.resolve(this.context, location);
assertThat(resolved).hasSize(1);
EnvConfigDataResource resource = resolved.get(0);
assertThat(resource.getLocation()).isEqualTo(location);
assertThat(resource.getVariableName()).isEqualTo("VAR1");
assertThat(resource.getLoader()).isInstanceOf(PropertiesPropertySourceLoader.class);
}
@Test
void shouldResolveWithYamlExtension() {
this.envVariables.put("VAR1", "VALUE1");
ConfigDataLocation location = ConfigDataLocation.of("env:VAR1[.yaml]");
List<EnvConfigDataResource> resolved = this.resolver.resolve(this.context, location);
assertThat(resolved).hasSize(1);
EnvConfigDataResource resource = resolved.get(0);
assertThat(resource.getLocation()).isEqualTo(location);
assertThat(resource.getVariableName()).isEqualTo("VAR1");
assertThat(resource.getLoader()).isInstanceOf(YamlPropertySourceLoader.class);
}
@Test
void shouldFailIfVariableIsNotSet() {
assertThatExceptionOfType(ConfigDataLocationNotFoundException.class)
.isThrownBy(() -> this.resolver.resolve(this.context, ConfigDataLocation.of("env:VAR1")))
.withMessage("Environment variable 'VAR1' is not set");
}
@Test
void shouldFailIfUnknownExtensionIsGiven() {
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolve(this.context, ConfigDataLocation.of("env:VAR1[.dummy]")))
.withMessage("File extension 'dummy' is not known to any PropertySourceLoader");
}
}

View File

@ -0,0 +1,65 @@
/*
* 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.context.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.PropertiesPropertySourceLoader;
import org.springframework.boot.env.PropertySourceLoader;
import org.springframework.boot.env.YamlPropertySourceLoader;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link EnvConfigDataResource}.
*
* @author Moritz Halbritter
*/
class EnvConfigDataResourceTests {
private final YamlPropertySourceLoader yamlPropertySourceLoader = new YamlPropertySourceLoader();
private final PropertiesPropertySourceLoader propertiesPropertySourceLoader = new PropertiesPropertySourceLoader();
@Test
void shouldHaveEqualsAndHashcode() {
EnvConfigDataResource var1 = createResource("VAR1");
EnvConfigDataResource var2 = createResource("VAR2");
EnvConfigDataResource var3 = createResource("VAR1", this.yamlPropertySourceLoader);
EnvConfigDataResource var4 = createResource("VAR1");
assertThat(var1).isNotEqualTo(var2);
assertThat(var1).isNotEqualTo(var3);
assertThat(var1).isEqualTo(var4);
assertThat(var1).hasSameHashCodeAs(var4);
}
@Test
void shouldHaveToString() {
EnvConfigDataResource resource = createResource("VAR1");
assertThat(resource).hasToString("env variable [VAR1]");
}
private EnvConfigDataResource createResource(String variableName) {
return createResource(variableName, this.propertiesPropertySourceLoader);
}
private EnvConfigDataResource createResource(String variableName, PropertySourceLoader propertySourceLoader) {
return new EnvConfigDataResource(ConfigDataLocation.of("env:" + variableName), variableName,
propertySourceLoader);
}
}

View File

@ -0,0 +1,14 @@
plugins {
id "java"
}
description = "Spring Boot Config smoke test"
dependencies {
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
}
tasks.named("test", Test) {
environment "SMOKE_TEST_CONFIG_ENV", "from-env.key1=value1"
}

View File

@ -0,0 +1,34 @@
/*
* 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 smoketest.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("from-env")
class FromEnvConfigurationProperties {
private String key1;
String getKey1() {
return this.key1;
}
void setKey1(String key1) {
this.key1 = key1;
}
}

View File

@ -0,0 +1,31 @@
/*
* 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 smoketest.config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(FromEnvConfigurationProperties.class)
public class SampleConfigApplication {
public static void main(String[] args) {
SpringApplication.run(SampleConfigApplication.class, args);
}
}

View File

@ -0,0 +1 @@
spring.config.import=optional:env:SMOKE_TEST_CONFIG_ENV

View File

@ -0,0 +1,42 @@
/*
* 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 smoketest.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link FromEnvConfigurationProperties}.
*
* @author Moritz Halbritter
*/
@SpringBootTest
class FromEnvConfigurationPropertiesTests {
@Autowired
private FromEnvConfigurationProperties properties;
@Test
void shouldHaveImportedValues() {
assertThat(this.properties.getKey1()).isEqualTo("value1");
}
}