diff --git a/docs/modules/ROOT/pages/server/environment-repository.adoc b/docs/modules/ROOT/pages/server/environment-repository.adoc index a4b63d04fc..663e89ae06 100644 --- a/docs/modules/ROOT/pages/server/environment-repository.adoc +++ b/docs/modules/ROOT/pages/server/environment-repository.adoc @@ -39,3 +39,52 @@ You can set `spring.cloud.config.server.accept-empty` to `false` so that Server NOTE: You cannot place `spring.main.*` properties in a remote `EnvironmentRepository`. These properties are used as part of the application initialization. +== File Content Resolution + +The Config Server can resolve the content of local files and serve them as Base64 encoded values. +This is particularly useful for serving binary files, such as SSL keystores or certificates, within configuration properties. + +To enable this feature, you must set the following property (it is disabled by default for security reasons): + +[source,yaml] +---- +spring: + cloud: + config: + server: + file-resolving: + enabled: true +---- + +WARNING: Enabling this feature allows the Config Server to read files from the local file system where the server is running. Ensure that the process has appropriate file system permissions and is running in a secure environment. + +==== Usage + +You can use the `{file}` prefix in your configuration values followed by the path to the file. +The Config Server will read the file, encode its content to a Base64 string, and replace the property value. + +**1. Absolute Path** + +You can reference files on the Config Server's local file system using an absolute path: + +[source,yaml] +---- +server: + ssl: + key-store: {file}/etc/certs/keystore.jks +---- + +**2. Relative Path (Repository-aware)** + +If you are using a repository that supports search paths (like Git, SVN, or Native), you can reference files **relative to the repository root** by starting the path with a dot (`.`): + +[source,yaml] +---- +server: + ssl: + # Resolves 'certs/keystore.jks' located inside the Git repository + key-store: {file}./certs/keystore.jks +---- + +In this case, the Config Server will look for the file inside the cloned repository directory. +If the repository does not support search paths (e.g., JDBC, Vault), relative paths will be ignored. diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfiguration.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfiguration.java new file mode 100644 index 0000000000..75eaa9dd7e --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-present 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.cloud.config.server.config; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.config.server.environment.EnvironmentRepository; +import org.springframework.cloud.config.server.environment.FileResolvingEnvironmentRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * Autoconfiguration for {@link FileResolvingEnvironmentRepository}. + * Wraps the existing EnvironmentRepository to support file content resolution. + * + * @author Johny Cho + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(EnvironmentRepositoryConfiguration.class) +public class FileResolvingEnvironmentRepositoryConfiguration { + + @Bean + @Primary + @ConditionalOnBean(EnvironmentRepository.class) + @ConditionalOnProperty(value = "spring.cloud.config.server.file-resolving.enabled", havingValue = "true") + public FileResolvingEnvironmentRepository fileResolvingEnvironmentRepository(EnvironmentRepository environmentRepository) { + return new FileResolvingEnvironmentRepository(environmentRepository); + } + +} diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java new file mode 100644 index 0000000000..51e98464a8 --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026-present 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.cloud.config.server.environment; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.environment.PropertySource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.util.FileCopyUtils; + +/** + * @author Johny Cho + */ +public class FileResolvingEnvironmentRepository implements EnvironmentRepository, SearchPathLocator { + + private static final Log log = LogFactory.getLog(FileResolvingEnvironmentRepository.class); + + private final EnvironmentRepository delegate; + + private final ResourceLoader resourceLoader = new DefaultResourceLoader(); + + private static final String PREFIX = "{file}"; + + public FileResolvingEnvironmentRepository(EnvironmentRepository delegate) { + this.delegate = delegate; + } + + @Override + public Environment findOne(String application, String profile, String label) { + Environment env = this.delegate.findOne(application, profile, label); + if (Objects.isNull(env)) { + return null; + } + + Locations locations = resolveLocations(application, profile, label); + List sources = env.getPropertySources(); + + for (int i = 0; i < sources.size(); i++) { + PropertySource source = sources.get(i); + PropertySource resolvedSource = processPropertySource(source, locations); + if (Objects.nonNull(resolvedSource)) { + sources.set(i, resolvedSource); + } + } + + return env; + } + + @Override + public Locations getLocations(String application, String profile, String label) { + return resolveLocations(application, profile, label); + } + + private Locations resolveLocations(String application, String profile, String label) { + if (this.delegate instanceof SearchPathLocator locator) { + return locator.getLocations(application, profile, label); + } + return new Locations(application, profile, label, null, new String[0]); + } + + /** + * Process a single PropertySource. Returns a new PropertySource if modification occurred, otherwise null. + */ + private PropertySource processPropertySource(PropertySource source, Locations locations) { + Map originalMap = source.getSource(); + Map modifiedMap = new LinkedHashMap<>(originalMap); + boolean modified = false; + + for (Map.Entry entry : originalMap.entrySet()) { + Object value = entry.getValue(); + if (value instanceof String str && str.startsWith(PREFIX)) { + String path = str.substring(PREFIX.length()); + String resolvedValue = resolveFileContent(entry.getKey().toString(), path, locations); + if (Objects.nonNull(resolvedValue)) { + modifiedMap.put(entry.getKey(), resolvedValue); + modified = true; + } + } + } + + return modified ? new PropertySource(source.getName(), modifiedMap) : null; + } + + private String resolveFileContent(String key, String path, Locations locations) { + try { + Resource resource = findResource(path, locations); + if (Objects.nonNull(resource) && resource.isReadable()) { + byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream()); + return Base64.getEncoder().encodeToString(content); + } + } + catch (IOException e) { + log.warn(String.format("Failed to resolve file content for '%s'. path: %s", key, path), e); + } + return null; + } + + private Resource findResource(String path, Locations locations) { + // 1. Try relative path if locations are available + if (path.startsWith(".") && Objects.nonNull(locations) && Objects.nonNull(locations.getLocations())) { + for (String location : locations.getLocations()) { + String resourceLocation = location + (location.endsWith("/") ? "" : "/") + path; + Resource candidate = this.resourceLoader.getResource(resourceLocation); + if (candidate.exists() && candidate.isReadable()) { + return candidate; + } + } + log.warn("Could not find relative file '" + path + "' in locations: " + Arrays.toString(locations.getLocations())); + return null; + } + + // 2. Fallback to absolute path or standard resource loading + Resource resource = this.resourceLoader.getResource("file:" + path); + if (!resource.exists()) { + resource = this.resourceLoader.getResource(path); + } + return resource; + } + +} diff --git a/spring-cloud-config-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-config-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index af9504d57c..a1c807b004 100644 --- a/spring-cloud-config-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-config-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,3 +4,4 @@ org.springframework.cloud.config.server.config.RsaEncryptionAutoConfiguration org.springframework.cloud.config.server.config.DefaultTextEncryptionAutoConfiguration org.springframework.cloud.config.server.config.EncryptionAutoConfiguration org.springframework.cloud.config.server.config.VaultEncryptionAutoConfiguration +org.springframework.cloud.config.server.config.FileResolvingEnvironmentRepositoryConfiguration diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfigurationTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfigurationTests.java new file mode 100644 index 0000000000..dd5744a0b0 --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfigurationTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026-present 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.cloud.config.server.config; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.config.server.environment.EnvironmentRepository; +import org.springframework.cloud.config.server.environment.FileResolvingEnvironmentRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link FileResolvingEnvironmentRepositoryConfiguration}. + */ +class FileResolvingEnvironmentRepositoryConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FileResolvingEnvironmentRepositoryConfiguration.class)); + + @Test + void shouldNotConfigureByDefault() { + this.contextRunner + .withUserConfiguration(MockRepositoryConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean(FileResolvingEnvironmentRepository.class)); + } + + @Test + void shouldNotConfigureIfExplicitlyDisabled() { + this.contextRunner + .withUserConfiguration(MockRepositoryConfiguration.class) + .withPropertyValues("spring.cloud.config.server.file-resolving.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(FileResolvingEnvironmentRepository.class)); + } + + @Test + void shouldConfigureIfExplicitlyEnabled() { + this.contextRunner + .withUserConfiguration(MockRepositoryConfiguration.class) + .withPropertyValues("spring.cloud.config.server.file-resolving.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(FileResolvingEnvironmentRepository.class); + assertThat(context.getBean(EnvironmentRepository.class)) + .isInstanceOf(FileResolvingEnvironmentRepository.class); + }); + } + + @Test + void shouldNotConfigureIfDelegateIsMissing() { + this.contextRunner + .withPropertyValues("spring.cloud.config.server.file-resolving.enabled=true") + .run(context -> assertThat(context).doesNotHaveBean(FileResolvingEnvironmentRepository.class)); + } + + @Configuration(proxyBeanMethods = false) + static class MockRepositoryConfiguration { + + @Bean + EnvironmentRepository environmentRepository() { + return mock(EnvironmentRepository.class); + } + + } + +} diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepositoryTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepositoryTests.java new file mode 100644 index 0000000000..f33531b486 --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepositoryTests.java @@ -0,0 +1,188 @@ +/* + * Copyright 2026-present 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.cloud.config.server.environment; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.environment.PropertySource; +import org.springframework.cloud.config.server.environment.SearchPathLocator.Locations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +/** + * Tests for {@link FileResolvingEnvironmentRepository}. + */ +class FileResolvingEnvironmentRepositoryTests { + + @TempDir + File tempDir; + + @Test + void findOneShouldResolveFileContentToBase64() throws Exception { + File secretFile = new File(tempDir, "secret.txt"); + String content = "hello-spring-cloud"; + Files.writeString(secretFile.toPath(), content); + + EnvironmentRepository delegate = mock(EnvironmentRepository.class); + Environment originalEnv = new Environment("app", "dev"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("my.secret", "{file}" + secretFile.getAbsolutePath()); + sourceMap.put("my.normal", "just-string"); + + PropertySource propertySource = new PropertySource("test-source", sourceMap); + originalEnv.add(propertySource); + + given(delegate.findOne(anyString(), anyString(), any())).willReturn(originalEnv); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + + Environment resultEnv = repository.findOne("app", "dev", null); + + assertThat(resultEnv).isNotNull(); + PropertySource resultSource = resultEnv.getPropertySources().get(0); + Map resultMap = resultSource.getSource(); + + String expectedBase64 = Base64.getEncoder().encodeToString(content.getBytes(StandardCharsets.UTF_8)); + assertThat(String.valueOf(resultMap.get("my.secret"))).isEqualTo(expectedBase64); + + assertThat(String.valueOf(resultMap.get("my.normal"))).isEqualTo("just-string"); + } + + @Test + void findOneShouldHandleNonExistentFile() { + EnvironmentRepository delegate = mock(EnvironmentRepository.class); + Environment originalEnv = new Environment("app", "dev"); + + Map sourceMap = new HashMap<>(); + String badPath = "{file}/path/to/non/existent/file.txt"; + sourceMap.put("my.bad.secret", badPath); + + originalEnv.add(new PropertySource("test-source", sourceMap)); + given(delegate.findOne(anyString(), anyString(), any())).willReturn(originalEnv); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + Environment resultEnv = repository.findOne("app", "dev", null); + + PropertySource resultSource = resultEnv.getPropertySources().get(0); + Map resultMap = resultSource.getSource(); + + assertThat(String.valueOf(resultMap.get("my.bad.secret"))).isEqualTo(badPath); + } + + @Test + void findOneShouldHandleUnmodifiableMapSafely() throws IOException { + File secretFile = new File(tempDir, "secret.txt"); + Files.write(secretFile.toPath(), "content".getBytes()); + + EnvironmentRepository delegate = mock(EnvironmentRepository.class); + Environment originalEnv = new Environment("app", "dev"); + + Map sourceMap = Collections.singletonMap("my.secret", "{file}" + secretFile.getAbsolutePath()); + + originalEnv.add(new PropertySource("immutable-source", sourceMap)); + given(delegate.findOne(anyString(), anyString(), any())).willReturn(originalEnv); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + + Environment resultEnv = repository.findOne("app", "dev", null); + + assertThat(resultEnv).isNotNull(); + Map resultMap = resultEnv.getPropertySources().get(0).getSource(); + + assertThat(String.valueOf(resultMap.get("my.secret"))).isNotEqualTo("{file}" + secretFile.getAbsolutePath()); + } + + @Test + void findOneShouldResolveRelativePathUsingLocations() throws Exception { + String filename = "relative-secret.txt"; + File secretFile = new File(tempDir, filename); + String content = "relative-content"; + Files.writeString(secretFile.toPath(), content); + + EnvironmentRepository delegate = mock(EnvironmentRepository.class, withSettings().extraInterfaces(SearchPathLocator.class)); + + Locations locations = new Locations("app", "dev", "label", "version", + new String[] { "file:" + tempDir.getAbsolutePath() + "/" }); + + given(((SearchPathLocator) delegate).getLocations(anyString(), anyString(), any())) + .willReturn(locations); + + Environment originalEnv = new Environment("app", "dev"); + Map sourceMap = new HashMap<>(); + sourceMap.put("my.relative", "{file}./" + filename); + originalEnv.add(new PropertySource("test-source", sourceMap)); + + given(delegate.findOne(anyString(), anyString(), any())).willReturn(originalEnv); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + Environment resultEnv = repository.findOne("app", "dev", null); + + assertThat(resultEnv).isNotNull(); + Map resultMap = resultEnv.getPropertySources().get(0).getSource(); + + String expectedBase64 = Base64.getEncoder().encodeToString(content.getBytes(StandardCharsets.UTF_8)); + assertThat(String.valueOf(resultMap.get("my.relative"))).isEqualTo(expectedBase64); + } + + @Test + void findOneShouldIgnoreRelativePathIfDelegateIsNotSearchPathLocator() { + EnvironmentRepository delegate = mock(EnvironmentRepository.class); + + Environment originalEnv = new Environment("app", "dev"); + Map sourceMap = new HashMap<>(); + sourceMap.put("my.ignored", "{file}./some/relative/path.txt"); + originalEnv.add(new PropertySource("test-source", sourceMap)); + + given(delegate.findOne(anyString(), anyString(), any())).willReturn(originalEnv); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + Environment resultEnv = repository.findOne("app", "dev", null); + + Map resultMap = resultEnv.getPropertySources().get(0).getSource(); + assertThat(String.valueOf(resultMap.get("my.ignored"))).isEqualTo("{file}./some/relative/path.txt"); + } + + @Test + void getLocationsShouldDelegateToUnderlyingRepository() { + EnvironmentRepository delegate = mock(EnvironmentRepository.class, withSettings().extraInterfaces(SearchPathLocator.class)); + + Locations expectedLocations = new Locations("app", "dev", "label", "v1", new String[] { "file:/tmp" }); + given(((SearchPathLocator) delegate).getLocations("app", "dev", null)).willReturn(expectedLocations); + + FileResolvingEnvironmentRepository repository = new FileResolvingEnvironmentRepository(delegate); + Locations result = repository.getLocations("app", "dev", null); + + assertThat(result).isSameAs(expectedLocations); + } +}