From 80f17d7a428da3c6795c8252dde1f546da7ae1e9 Mon Sep 17 00:00:00 2001 From: johnycho Date: Wed, 14 Jan 2026 20:28:21 +0900 Subject: [PATCH 1/4] Support resolving file content to Base64 via {file} prefix Signed-off-by: johnycho --- ...ingEnvironmentRepositoryConfiguration.java | 30 +++++ .../FileResolvingEnvironmentRepository.java | 76 +++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...leResolvingEnvironmentRepositoryTests.java | 107 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfiguration.java create mode 100644 spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java create mode 100644 spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepositoryTests.java 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..133c8f5c2a --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfiguration.java @@ -0,0 +1,30 @@ +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", matchIfMissing = 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..819c8ac5d0 --- /dev/null +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java @@ -0,0 +1,76 @@ +package org.springframework.cloud.config.server.environment; + +import java.io.File; +import java.io.IOException; +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.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * @author Johny Cho + */ +public class FileResolvingEnvironmentRepository implements EnvironmentRepository { + + private static final Log log = LogFactory.getLog(FileResolvingEnvironmentRepository.class); + private final EnvironmentRepository delegate; + 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; + } + + List sources = env.getPropertySources(); + + for (int i = 0; i < sources.size(); i++) { + PropertySource source = sources.get(i); + 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 filePath = str.substring(PREFIX.length()); + try { + String base64Content = readFileToBase64(filePath); + modifiedMap.put(entry.getKey(), base64Content); + modified = true; + } catch (IOException e) { + log.warn(String.format("Failed to resolve file content for property '%s'. path: %s", entry.getKey(), filePath), e); + } + } + } + + if (modified) { + PropertySource newSource = new PropertySource(source.getName(), modifiedMap); + sources.set(i, newSource); + } + } + + return env; + } + + private String readFileToBase64(String filePath) throws IOException { + File file = ResourceUtils.getFile(filePath); + byte[] fileContent = FileCopyUtils.copyToByteArray(file); + return Base64.getEncoder().encodeToString(fileContent); + } +} 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/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..7463433b6e --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepositoryTests.java @@ -0,0 +1,107 @@ +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 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; + +/** + * 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()); + } +} From 24b60e3171934728eb92781572fa385ebcbdf420 Mon Sep 17 00:00:00 2001 From: johnycho Date: Fri, 16 Jan 2026 13:03:38 +0900 Subject: [PATCH 2/4] Address review comments: disable by default and add docs Signed-off-by: johnycho --- .../pages/server/environment-repository.adoc | 33 ++++++++ ...ingEnvironmentRepositoryConfiguration.java | 18 +++- .../FileResolvingEnvironmentRepository.java | 20 ++++- ...vironmentRepositoryConfigurationTests.java | 83 +++++++++++++++++++ ...leResolvingEnvironmentRepositoryTests.java | 16 ++++ 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfigurationTests.java diff --git a/docs/modules/ROOT/pages/server/environment-repository.adoc b/docs/modules/ROOT/pages/server/environment-repository.adoc index a4b63d04fc..ac6b0771e1 100644 --- a/docs/modules/ROOT/pages/server/environment-repository.adoc +++ b/docs/modules/ROOT/pages/server/environment-repository.adoc @@ -39,3 +39,36 @@ 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. + +When this feature is enabled, 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. + +For example, in your backing repository (e.g., Git or Native), you can define a property like this: + +[source,yaml] +---- +server: + ssl: + key-store: {file}/etc/certs/keystore.jks + key-password: my-secret-password +---- + +In this example, the Config Server reads `/etc/certs/keystore.jks`, encodes it, and returns the Base64 string as the value of `server.ssl.key-store`. 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 index 133c8f5c2a..7d24877c7a 100644 --- 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 @@ -1,3 +1,19 @@ +/* + * Copyright 2024-2026 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; @@ -22,7 +38,7 @@ public class FileResolvingEnvironmentRepositoryConfiguration { @Bean @Primary @ConditionalOnBean(EnvironmentRepository.class) - @ConditionalOnProperty(value = "spring.cloud.config.server.file-resolving.enabled", matchIfMissing = true) + @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 index 819c8ac5d0..e6fb451f3f 100644 --- 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 @@ -1,3 +1,19 @@ +/* + * Copyright 2024-2026 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; @@ -7,6 +23,7 @@ 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; @@ -53,7 +70,8 @@ public Environment findOne(String application, String profile, String label) { String base64Content = readFileToBase64(filePath); modifiedMap.put(entry.getKey(), base64Content); modified = true; - } catch (IOException e) { + } + catch (IOException e) { log.warn(String.format("Failed to resolve file content for property '%s'. path: %s", entry.getKey(), filePath), e); } } 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..0ddcab5c9f --- /dev/null +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/config/FileResolvingEnvironmentRepositoryConfigurationTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024-2026 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 index 7463433b6e..f86708799d 100644 --- 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 @@ -1,3 +1,19 @@ +/* + * Copyright 2024-2026 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; From 50db4c8c30fecf76570fd64070770da2187a0517 Mon Sep 17 00:00:00 2001 From: johnycho Date: Fri, 16 Jan 2026 13:16:03 +0900 Subject: [PATCH 3/4] Fix the Checkstyle errors Signed-off-by: johnycho --- .../FileResolvingEnvironmentRepositoryConfiguration.java | 2 +- .../server/environment/FileResolvingEnvironmentRepository.java | 3 ++- .../FileResolvingEnvironmentRepositoryConfigurationTests.java | 2 +- .../environment/FileResolvingEnvironmentRepositoryTests.java | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) 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 index 7d24877c7a..75eaa9dd7e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2026 the original author or authors. + * 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. 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 index e6fb451f3f..aff792a3da 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2026 the original author or authors. + * 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. @@ -26,6 +26,7 @@ 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.util.FileCopyUtils; 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 index 0ddcab5c9f..dd5744a0b0 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2026 the original author or authors. + * 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. 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 index f86708799d..e58ddda8a5 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2026 the original author or authors. + * 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. From 15d2c9ad3c413de20bf5be0b46f3815bb1a23d6a Mon Sep 17 00:00:00 2001 From: johnycho Date: Mon, 19 Jan 2026 15:14:31 +0900 Subject: [PATCH 4/4] Support relative paths in FileResolvingEnvironmentRepository Signed-off-by: johnycho --- .../pages/server/environment-repository.adoc | 24 +++- .../FileResolvingEnvironmentRepository.java | 113 +++++++++++++----- ...leResolvingEnvironmentRepositoryTests.java | 65 ++++++++++ 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/docs/modules/ROOT/pages/server/environment-repository.adoc b/docs/modules/ROOT/pages/server/environment-repository.adoc index ac6b0771e1..663e89ae06 100644 --- a/docs/modules/ROOT/pages/server/environment-repository.adoc +++ b/docs/modules/ROOT/pages/server/environment-repository.adoc @@ -58,17 +58,33 @@ spring: 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. -When this feature is enabled, you can use the `{file}` prefix in your configuration values followed by the path to the file. +==== 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. -For example, in your backing repository (e.g., Git or Native), you can define a property like this: +**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 - key-password: my-secret-password ---- -In this example, the Config Server reads `/etc/certs/keystore.jks`, encodes it, and returns the Base64 string as the value of `server.ssl.key-store`. +**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/environment/FileResolvingEnvironmentRepository.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/FileResolvingEnvironmentRepository.java index aff792a3da..51e98464a8 100644 --- 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 @@ -16,8 +16,8 @@ package org.springframework.cloud.config.server.environment; -import java.io.File; import java.io.IOException; +import java.util.Arrays; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; @@ -29,16 +29,22 @@ 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; -import org.springframework.util.ResourceUtils; /** * @author Johny Cho */ -public class FileResolvingEnvironmentRepository implements EnvironmentRepository { +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) { @@ -48,48 +54,93 @@ public FileResolvingEnvironmentRepository(EnvironmentRepository 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); - 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 filePath = str.substring(PREFIX.length()); - try { - String base64Content = readFileToBase64(filePath); - modifiedMap.put(entry.getKey(), base64Content); - modified = true; - } - catch (IOException e) { - log.warn(String.format("Failed to resolve file content for property '%s'. path: %s", entry.getKey(), filePath), e); - } - } + 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]); + } - if (modified) { - PropertySource newSource = new PropertySource(source.getName(), modifiedMap); - sources.set(i, newSource); + /** + * 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 env; + 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 String readFileToBase64(String filePath) throws IOException { - File file = ResourceUtils.getFile(filePath); - byte[] fileContent = FileCopyUtils.copyToByteArray(file); - return Base64.getEncoder().encodeToString(fileContent); + 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/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 index e58ddda8a5..f33531b486 100644 --- 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 @@ -30,12 +30,14 @@ 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}. @@ -120,4 +122,67 @@ void findOneShouldHandleUnmodifiableMapSafely() throws IOException { 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); + } }