Problem Description:
After upgrading our Spring Boot application from version 3.3.x to 3.4.6, we're consistently encountering PropertyNotFoundException
errors during application startup. Our application relies on spring.config.import
with sm://
or sm@
prefixes to dynamically fetch secrets from a cloud Secret Manager (e.g., GCP Secret Manager) and inject them into the Spring Environment.
Observed Behavior in Spring Boot 3.4.6:
The application fails to start because certain components or beans attempt to access properties (e.g., spring.data.database.uri
, spring.data.database.username
, spring.data.database.password
) that are expected to be resolved from sm://
or sm@
placeholders. However, these properties are not yet available in the Spring Environment at the time of access. This indicates that the spring.config.import
mechanism for these external secrets is completing after or too late for some early bean initialization phases.
Expected Behavior in Spring Boot 3.3.x:
In Spring Boot 3.3.x, this setup worked reliably. The spring.config.import
mechanism for sm://
or sm@
prefixes would consistently load all necessary secrets into the Spring Environment before any beans that depended on these secrets were initialized. No PropertyNotFoundException
or similar startup failures were observed related to secret resolution timing.
Context / Suspected Change:
This change in behavior suggests a shift in the timing or order of property loading and spring.config.import
processing relative to bean initialization between Spring Boot 3.3.x and 3.4.x.
Workaround Implemented:
To mitigate this issue and allow the application to start, we've implemented a custom EnvironmentPostProcessor
to fetch and inject secrets very early in the Spring Boot startup process.
-
Declare
EnvironmentPostProcessor
:src/main/resources/META-INF/spring.factories
:properties org.springframework.boot.env.EnvironmentPostProcessor=\ com.example.app.config.CustomSecretManagerEnvProcessor
-
Implement
CustomSecretManagerEnvProcessor
: This class is ordered with@Order(Ordered.HIGHEST_PRECEDENCE + 11)
to ensure early execution. It reads a mapping fromapplication.yml
, fetches the corresponding secrets from our Secret Manager, and then adds them directly to Spring'sConfigurableEnvironment
usingenv.propertySources.addFirst()
.```java package com.example.app.config;
import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; import java.util.HashMap; import java.util.Map; // Potentially import org.springframework.boot.env.YamlPropertySourceLoader; // Or similar to read the secrets.mapping if it's complex
@Order(Ordered.HIGHEST_PRECEDENCE + 11) // Ensures very early execution public class CustomSecretManagerEnvProcessor implements EnvironmentPostProcessor {
private static final String CUSTOM_PROPERTY_SOURCE_NAME = "custom-secret-properties"; // private static final String YAML_SECRETS_MAPPING_PATH = "secrets.mapping"; // If reading from env @Override public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) { final Map<String, Object> loadedSecretProps = new HashMap<>(); // MASKED: Logic to read 'secrets.mapping' from YAML // Example: Access properties like env.getProperty("secrets.mapping.secret-name") // MASKED: Logic to initialize Secret Manager client (e.g., GCP Secret Manager client) // and fetch secrets based on the 'secrets.mapping' configuration. // Example structure: // List<Map<String, String>> mappings = (List<Map<String, String>>) env.getProperty("secrets.mapping", List.class); // if (mappings != null) { // SecretManagerClient client = initializeSecretManagerClient(); // Your client init // for (Map<String, String> mapping : mappings) { // String secretId = mapping.get("secret-name"); // String propertyKey = mapping.get("property-key"); // if (secretId != null && propertyKey != null) { // String rawSecret = client.getSecret(secretId); // Your secret fetch logic // loadedSecretProps.put(propertyKey, rawSecret); // } // } // } // Example hardcoded for demonstration (replace with actual fetching based on mapping) loadedSecretProps.put("masked.db.password", "fetchedPassword123"); loadedSecretProps.put("masked.azure.client-id", "fetchedClientId"); if (!loadedSecretProps.isEmpty()) { env.propertySources.addFirst( new MapPropertySource(CUSTOM_PROPERTY_SOURCE_NAME, loadedSecretProps) ); } }
} ```
-
Define Secret Mapping in
application.yml
: This section specifies which secrets theEnvironmentPostProcessor
should fetch and map to Spring properties.```yaml
application.yml snippet
spring: cloud: gcp: secretmanager: enabled: false # Optional: Disable default if it conflicts/duplicates # ... other configurations
This section drives the CustomSecretManagerEnvProcessor
secrets: mapping: - secret-name: "your-gcp-secret-id-1" # Name in Secret Manager property-key: "masked.db.password" # Property key in Spring env - secret-name: "your-gcp-secret-id-2" property-key: "masked.azure.client-id" # ... more mappings
Example of consuming the properties after the EnvironmentPostProcessor has run
app: datasource: password: ${masked.db.password} azure: clientId: ${masked.azure.client-id} ```
This workaround ensures that critical secrets are loaded and available in the Spring Environment at a very early stage, bypassing the timing issues experienced with spring.config.import
in Spring Boot 3.4.x.
Question for the Spring Boot Team:
Is this change in the timing of spring.config.import
for external secrets an intentional architectural shift in Spring Boot 3.4.x, or could it be an unintended regression? Understanding the intended behavior would help us determine if our EnvironmentPostProcessor
workaround is the recommended approach for such scenarios or if there's a more idiomatic/built-in way to handle early secret loading in 3.4.x.
Example application.yml
(original, problematic setup):
```yaml
application.yml
spring:
config:
import: sm://
data:
database:
uri: ${sm://
Comment From: wilkinsona
spring.config.import
is processed as part of preparing the environment. That has not (intentionally) changed in Spring Boot 3.4. None of the properties that you have mentioned (spring.data.database.uri
etc) are standard properties so we don't know how they're being consumed. Without knowing that, we can't really offer any more guidance here. On the face of it, given that your own environment post-processor fixes the problem, it would appear to be a problem with the Spring Cloud GCP secret manager integration.
If you would like us to spend some more time investigating, please spend some time providing a complete yet minimal sample that reproduces the problem. You can share it with us by pushing it to a separate repository on GitHub or by zipping it up and attaching it to this issue.