Skip to content

Commit

Permalink
Support config yaml files embedded in env vars via spring.config.import
Browse files Browse the repository at this point in the history
  • Loading branch information
chicobento committed Jan 3, 2025
1 parent 51d15c7 commit 3e2c7b4
Show file tree
Hide file tree
Showing 13 changed files with 227 additions and 15 deletions.
2 changes: 2 additions & 0 deletions spring-boot-project/spring-boot/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +94,13 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp
void postProcessEnvironment(ConfigurableEnvironment environment, ResourceLoader resourceLoader,
Collection<String> 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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,11 +77,11 @@
*/
class ConfigDataEnvironmentPostProcessorIntegrationTests {

private SpringApplication application;

@TempDir
public File temp;

private SpringApplication application;

@BeforeEach
void setup() {
this.application = new SpringApplication(Config.class);
Expand Down Expand Up @@ -612,7 +614,8 @@ void runWhenImportFromEarlierDocumentUsesPlaceholder() {
assertThat(context.getEnvironment().getProperty("my.value")).isEqualTo("iwasimported");
}

@Test // gh-26858
@Test
// gh-26858
void runWhenImportWithProfileVariantOrdersPropertySourcesCorrectly() {
this.application.setAdditionalProfiles("dev");
ConfigurableApplicationContext context = this.application
Expand All @@ -621,6 +624,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");
Expand All @@ -637,14 +696,16 @@ void runWhenHasPropertyInProfileDocumentThrowsException() {
.withCauseInstanceOf(InactiveConfigDataAccessException.class);
}

@Test // gh-29386
@Test
// gh-29386
void runWhenHasPropertyInEarlierProfileDocumentThrowsException() {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run(
"--spring.config.location=classpath:application-import-with-placeholder-in-earlier-profile-document.properties"))
.withCauseInstanceOf(InactiveConfigDataAccessException.class);
}

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

@Test // gh-24990
@Test
// gh-24990
void runWhenHasProfileSpecificFileWithActiveOnProfileProperty() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=application-activate-on-profile-in-profile-specific-file");
Expand All @@ -778,7 +840,8 @@ void runWhenHasProfileSpecificFileWithActiveOnProfileProperty() {
assertThat(environment.getProperty("test2")).isEqualTo("test2");
}

@Test // gh-26960
@Test
// gh-26960
void runWhenHasProfileSpecificImportWithImportImportsSecondProfileSpecificFile() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=application-profile-specific-import-with-import");
Expand All @@ -791,7 +854,8 @@ void runWhenHasProfileSpecificImportWithImportImportsSecondProfileSpecificFile()
assertThat(environment.containsProperty("application-profile-specific-import-with-import-import-p2")).isTrue();
}

@Test // gh-26960
@Test
// gh-26960
void runWhenHasProfileSpecificImportWithCustomImportResolvesProfileSpecific() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=application-profile-specific-import-with-custom-import");
Expand All @@ -800,7 +864,8 @@ void runWhenHasProfileSpecificImportWithCustomImportResolvesProfileSpecific() {
assertThat(environment.containsProperty("test:boot:ps")).isTrue();
}

@Test // gh-26593
@Test
// gh-26593
void runWhenHasFilesInRootAndConfigWithProfiles() {
ConfigurableApplicationContext context = this.application
.run("--spring.config.name=file-in-root-and-config-with-profile", "--spring.profiles.active=p1,p2");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ void initializeAddsProtocolResolversToApplicationContext() {
ProtocolResolverApplicationContextInitializer initializer = new ProtocolResolverApplicationContextInitializer();
initializer.initialize(context);
assertThat(context).isInstanceOf(DefaultResourceLoader.class);
Collection<ProtocolResolver> protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers();
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class);
Collection<ProtocolResolver>

protocolResolvers = ((DefaultResourceLoader) context).getProtocolResolvers();
assertThat(protocolResolvers).hasExactlyElementsOfTypes(Base64ProtocolResolver.class,
EnvironmentVariableProtocolResolver.class);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
my.value=application-import-base64-yaml-from-environment
spring.config.import=env:base64:MY_CONFIG_BASE64_YAML[.yaml]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
my.value=application-import-properties-from-environment
spring.config.import=env:MY_CONFIG_PROPERTIES[.properties]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
spring.config.import=env:MY_CONFIG_YAML[.yaml]
my.value=application-import-from-environment-with-profile-variant
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
my.value=application-import-yaml-from-environment
spring.config.import=env:MY_CONFIG_YAML[.yaml]

0 comments on commit 3e2c7b4

Please sign in to comment.