Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,25 @@
package org.apache.camel.component.hashicorp.vault.springboot;

import org.apache.camel.RuntimeCamelException;
import org.apache.camel.component.hashicorp.vault.HashicorpVaultClient;
import org.apache.camel.component.hashicorp.vault.HashicorpVaultClientSettings;
import org.apache.camel.component.hashicorp.vault.HashicorpVaultPropertiesFunction;
import org.apache.camel.spi.PropertiesFunction;
import org.apache.camel.spring.boot.AbstractEarlyResolutionPropertiesParser;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.vault.HashicorpVaultConfiguration;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.vault.authentication.TokenAuthentication;
import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.core.VaultTemplate;

/**
* Resolves the {@code {{hashicorp:...}}} placeholders of the Spring Boot properties before the CamelContext exists. It
* logs in to HashiCorp Vault with the {@code camel.vault.hashicorp} options, as the properties function of the
* CamelContext does, and revokes the token of its login once the properties are resolved.
*/
public class SpringBootHashicorpVaultPropertiesParser extends AbstractEarlyResolutionPropertiesParser {

private static final String PREFIX = "camel.vault.hashicorp";

@Override
protected String getEarlyResolutionProperty() {
return "camel.component.hashicorp-vault.early-resolve-properties";
Expand All @@ -40,35 +48,62 @@

@Override
protected PropertiesFunction createPropertiesFunction(ConfigurableEnvironment environment) {
String token = required(environment, "camel.vault.hashicorp.token", "Hashicorp Vault token is required");
String host = required(environment, "camel.vault.hashicorp.host", "Hashicorp Vault host is required");
String portValue = required(environment, "camel.vault.hashicorp.port", "Hashicorp Vault port is required");
String scheme = required(environment, "camel.vault.hashicorp.scheme", "Hashicorp Vault scheme is required");

int port;
// bound as Spring Boot binds the options of the CamelContext, such as camel.vault.hashicorp.role-id
HashicorpVaultConfiguration configuration = Binder.get(environment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolvable ${...} no longer fails fast. Before, environment.getProperty threw "Could not resolve placeholder" for camel.vault.hashicorp.token=${VAULT_TOKEN} with VAULT_TOKEN unset. Binder.get(environment) uses PropertySourcesPlaceholdersResolver, which ignores unresolvable placeholders and keeps the literal text. So required() passes and ${VAULT_TOKEN} is sent as X-Vault-Token (or used as the host), which gives a vague 403 / URI error for each placeholder. With camel.vault.ignore-resolution-failures=true startup goes on with the placeholders unresolved. Checking the bound values for a leftover ${ would keep the old behaviour.

Claude Code (Opus 5.5) on behalf of Federico Mariani

.bind(PREFIX, HashicorpVaultConfiguration.class)
.orElseGet(HashicorpVaultConfiguration::new);
if (isTokenLogin(configuration)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: isTokenLogin, the host/port/scheme/token checks and the Integer.parseInt repeat what HashicorpVaultClient.create already does through HashicorpVaultClient.validate(settings) and HashicorpVaultAuthentication.of. If Camel changes the default login or how authentication is normalised, isTokenLogin drifts silently. Calling validate and mapping its IllegalArgumentException to the current messages would keep one copy of the rules.

Claude Code (Opus 5.5) on behalf of Federico Mariani

required(configuration.getToken(), "token", "Hashicorp Vault token is required");
}
required(configuration.getHost(), "host", "Hashicorp Vault host is required");
required(configuration.getPort(), "port", "Hashicorp Vault port is required");
required(configuration.getScheme(), "scheme", "Hashicorp Vault scheme is required");
try {
port = Integer.parseInt(portValue);
Integer.parseInt(configuration.getPort());
} catch (NumberFormatException e) {
throw new RuntimeCamelException(
"camel.vault.hashicorp.port must be a number but was: " + portValue, e);
PREFIX + ".port must be a number but was: " + configuration.getPort(), e);
}

VaultEndpoint vaultEndpoint = new VaultEndpoint();
vaultEndpoint.setHost(host);
vaultEndpoint.setPort(port);
vaultEndpoint.setScheme(scheme);
HashicorpVaultClient client;
try {
client = HashicorpVaultClient.create(HashicorpVaultClientSettings.fromConfiguration(configuration));

Check failure on line 70 in components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java

View workflow job for this annotation

GitHub Actions / build (25)

cannot find symbol

Check failure on line 70 in components-starter/camel-hashicorp-vault-starter/src/main/java/org/apache/camel/component/hashicorp/vault/springboot/SpringBootHashicorpVaultPropertiesParser.java

View workflow job for this annotation

GitHub Actions / build (25)

cannot find symbol

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early resolution and the CamelContext can use different settings. This uses HashicorpVaultClientSettings.fromConfiguration, while HashicorpVaultPropertiesFunction on the CamelContext uses fromEnvironmentOrConfiguration, where the CAMEL_HASHICORP_VAULT_* variables win as soon as one of them is set. The fromConfiguration Javadoc says it is meant for Spring Boot, but on Spring Boot the CamelContext function still reads those variables, so "as the properties function of the CamelContext does" in the class Javadoc doesn't hold there:

  • Only CAMEL_HASHICORP_VAULT_HOST / CAMEL_HASHICORP_VAULT_TOKEN set (as the Camel docs describe): the CamelContext resolves {{hashicorp:...}}, the early resolution fails with "Hashicorp Vault token is required".
  • Both set: the early resolution logs in with camel.vault.hashicorp.* (say AppRole), the CamelContext with the environment token.

Either use fromEnvironmentOrConfiguration here too, or document that the early resolution reads only camel.vault.hashicorp.*.

Claude Code (Opus 5.5) on behalf of Federico Mariani

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppRole/JWT logins now happen twice per startup. The early resolution logs in with the same credentials that the CamelContext function and the secret refresh task use later. With secret_id_num_uses=1 (common with response-wrapped secret IDs), the early resolution uses up the secret ID, and the later CamelContext login fails with "invalid secret id": startup looks fine, then the hashicorp: lookups in routes fail. Worth at least a note in the docs.

Claude Code (Opus 5.5) on behalf of Federico Mariani

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the token login now sends an extra auth/token/lookup-self. Before, it used new VaultTemplate(endpoint, new TokenAuthentication(token)), i.e. a SimpleSessionManager. The client now goes through LifecycleAwareSessionManager, which does a token self-lookup by default. A token created with num_uses=N for N early-resolved secrets now runs out one read early (403 on the last), and a token whose policy lacks lookup-self logs "Cannot enhance VaultToken to a LoginToken" at WARN on every startup. It's a behaviour change for the token login, which the PR otherwise keeps as it was.

Claude Code (Opus 5.5) on behalf of Federico Mariani

} catch (IllegalArgumentException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: only IllegalArgumentException gets the message that names the camel.vault.hashicorp options. Other failures from create, such as an IllegalStateException (or NoClassDefFoundError) when authentication=aws-iam and a needed AWS SDK module is missing, abort startup without that hint. Catching RuntimeException here would cover more cases.

Claude Code (Opus 5.5) on behalf of Federico Mariani

throw new RuntimeCamelException(
"Cannot create the Hashicorp Vault client of the " + PREFIX + " options: " + e.getMessage(), e);
}
return new EarlyResolutionFunction(client);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed login is retried once per placeholder. create doesn't log in; the LifecycleAwareSessionManager logs in on the first read and keeps no token when the login fails, so every apply() logs in again. The base class then goes through all the placeholders to report them together.

With a stale AppRole secret-id and 6 hashicorp: placeholders, one startup sends 6 failed auth/approle/login calls. Vault's user lockout covers AppRole (default threshold 5, 15 minutes), so the role_id is locked for every pod that shares it, the CamelContext login included, even after the secret-id is fixed. The old token-only path never logged in. Stopping after the first login failure, or logging in once in createPropertiesFunction, would avoid this.

Claude Code (Opus 5.5) on behalf of Federico Mariani

}

VaultTemplate client = new VaultTemplate(
vaultEndpoint,
new TokenAuthentication(token));
return new HashicorpVaultPropertiesFunction(client);
@Override
protected void release(PropertiesFunction propertiesFunction) {
if (propertiesFunction instanceof EarlyResolutionFunction function) {
// stops renewing the token, and revokes the token of the login (but not a token given for the token login)
function.client.close();
}
}

private static boolean isTokenLogin(HashicorpVaultConfiguration configuration) {
String authentication = configuration.getAuthentication();
return ObjectHelper.isEmpty(authentication) || "token".equalsIgnoreCase(authentication.trim());
}

private static String required(ConfigurableEnvironment environment, String key, String message) {
String value = environment.getProperty(key);
private static void required(String value, String option, String message) {
if (ObjectHelper.isEmpty(value)) {
throw new RuntimeCamelException(message + " (set " + key + ")");
throw new RuntimeCamelException(message + " (set " + PREFIX + "." + option + ")");
}
}

/**
* The properties function of the early resolution, with the client it reads the secrets with.
*/
private static final class EarlyResolutionFunction extends HashicorpVaultPropertiesFunction {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion for apache/camel#29819 rather than this PR. The private subclass and the instanceof check in release() work around HashicorpVaultPropertiesFunction not being able to take and own a HashicorpVaultClient. Because the subclass can pass only a VaultTemplate, it also loses cloud and namespace (comment on line 105). A public constructor or factory in camel#29819 that takes the client and its settings, and closes the client when the function stops, would fix both, and other runtimes wouldn't need to re-implement it.

Claude Code (Opus 5.5) on behalf of Federico Mariani


private final HashicorpVaultClient client;

private EarlyResolutionFunction(HashicorpVaultClient client) {
super(client.getVaultTemplate());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HCP Vault: the secrets are read outside the namespace. super(client.getVaultTemplate()) uses the public HashicorpVaultPropertiesFunction(VaultTemplate) constructor, which leaves cloud=false and namespace=null. HashicorpVaultClient sends the login to the namespace (its loginClient), but apply() adds the namespace/ prefix to the secret path only when cloud and namespace are set.

So with camel.vault.hashicorp.cloud=true, namespace=admin and authentication=approle, the early resolution reads secret/data/key instead of admin/secret/data/key, gets a 403 and aborts startup, while the CamelContext reads the right path. The constructor that takes cloud and namespace is package-private in apache/camel#29819, so the starter can't pass them (see the comment on line 100).

Claude Code (Opus 5.5) on behalf of Federico Mariani

this.client = client;
}
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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
*
* http://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.apache.camel.component.hashicorp.vault.springboot;

import org.apache.camel.spring.boot.CamelAutoConfiguration;
import org.apache.camel.test.infra.hashicorp.vault.services.HashicorpServiceFactory;
import org.apache.camel.test.infra.hashicorp.vault.services.HashicorpVaultService;
import org.apache.camel.test.spring.junit6.CamelSpringBootTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.vault.authentication.TokenAuthentication;
import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.core.VaultKeyValueOperations;
import org.springframework.vault.core.VaultKeyValueOperationsSupport;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.support.VaultMount;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* The early resolution logs in with the AppRole login of the {@code camel.vault.hashicorp} options, and revokes the
* token of its login once the properties are resolved.
*/
@CamelSpringBootTest
@DirtiesContext
@SpringBootApplication
@SpringBootTest(
classes = { EarlyResolvedPropertiesAppRoleTest.TestConfiguration.class },
properties = {
"camel.component.hashicorp-vault.early-resolve-properties=true",
"early.resolved.property.approle={{hashicorp:secret:approle#string}}"
})
class EarlyResolvedPropertiesAppRoleTest {

@RegisterExtension
static HashicorpVaultService service = HashicorpServiceFactory.createService();

// the system properties are seen by the early resolution, and the JVM is shared with the other tests
private static final List<String> OPTIONS = List.of(
"camel.vault.hashicorp.host", "camel.vault.hashicorp.port", "camel.vault.hashicorp.scheme",
"camel.vault.hashicorp.token", "camel.vault.hashicorp.authentication", "camel.vault.hashicorp.role-id",
"camel.vault.hashicorp.secret-id");

@BeforeAll
static void setup() {
VaultTemplate root = root();
VaultKeyValueOperations secrets
= root.opsForKeyValue("secret", VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
secrets.put("approle", Map.of("string", "from-approle"));
// read by application.properties
secrets.put("database/password", Map.of("string", "pazzword"));

root.write("sys/policies/acl/early-resolution",
Map.of("policy", "path \"secret/data/*\" { capabilities = [\"read\"] }"));
if (!root.opsForSys().getAuthMounts().containsKey("approle/")) {
root.opsForSys().authMount("approle", VaultMount.create("approle"));
}
root.write("auth/approle/role/camel", Map.of("token_policies", "early-resolution"));
String roleId = (String) root.read("auth/approle/role/camel/role-id").getRequiredData().get("role_id");
String secretId = (String) root.write("auth/approle/role/camel/secret-id", Map.of())
.getRequiredData().get("secret_id");

// no token: the AppRole login gets one
OPTIONS.forEach(System::clearProperty);
System.setProperty("camel.vault.hashicorp.host", service.host());
System.setProperty("camel.vault.hashicorp.port", String.valueOf(service.port()));
System.setProperty("camel.vault.hashicorp.scheme", "http");
// the kebab-case names of Spring Boot
System.setProperty("camel.vault.hashicorp.authentication", "approle");
System.setProperty("camel.vault.hashicorp.role-id", roleId);
System.setProperty("camel.vault.hashicorp.secret-id", secretId);
}

@AfterAll
static void clearOptions() {
OPTIONS.forEach(System::clearProperty);
}

@Value("${early.resolved.property}")
private String earlyResolvedProperty;

@Value("${early.resolved.property.approle}")
private String earlyResolvedPropertyAppRole;

@Test
void resolvesThePropertiesWithTheAppRoleLoginAndRevokesItsToken() {
assertEquals("pazzword", earlyResolvedProperty);
assertEquals("from-approle", earlyResolvedPropertyAppRole);

await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(appRoleTokens().isEmpty(),
"the token of the AppRole login must be revoked once the properties are resolved"));
}

private static VaultTemplate root() {
VaultEndpoint endpoint = VaultEndpoint.create(service.host(), service.port());
endpoint.setScheme("http");
return new VaultTemplate(endpoint, new TokenAuthentication(service.token()));
}

// the accessors of the tokens that the AppRole login gave and Vault has not revoked
private static List<String> appRoleTokens() {
VaultTemplate root = root();
return root.list("auth/token/accessors").stream()
.filter(accessor -> "auth/approle/login".equals(
root.write("auth/token/lookup-accessor", Map.of("accessor", accessor))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible flake: if a token is revoked between list("auth/token/accessors") and lookup-accessor, Vault answers 400 "invalid accessor" and write throws a VaultException. untilAsserted retries only on AssertionError, so the await fails at once instead of polling again, and that window is exactly when the release revokes the token. .ignoreExceptions() on the await, or skipping accessors whose lookup fails, would make it stable.

Claude Code (Opus 5.5) on behalf of Federico Mariani

.getRequiredData().get("path")))
.toList();
}

@Configuration
@AutoConfigureBefore(CamelAutoConfiguration.class)
static class TestConfiguration {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.apache.camel.component.hashicorp.vault.springboot;

import org.apache.camel.RuntimeCamelException;
import org.apache.camel.spi.PropertiesFunction;
import org.junit.jupiter.api.Test;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
Expand Down Expand Up @@ -117,4 +118,39 @@ public void nonNumericPortFailsAsACamelExceptionNamingTheProperty() {
assertInstanceOf(NumberFormatException.class, thrown.getCause(),
"the cause must be preserved so the original NumberFormatException is not lost");
}

@Test
void anAppRoleLoginNeedsNoToken() {
// the kebab-case names of Spring Boot, as for the options of the CamelContext
PropertiesFunction function = parser.createPropertiesFunction(environmentWith(Map.of(
"camel.vault.hashicorp.host", "127.0.0.1",
"camel.vault.hashicorp.port", "8200",
"camel.vault.hashicorp.scheme", "http",
"camel.vault.hashicorp.authentication", "approle",
"camel.vault.hashicorp.role-id", "a-role-id",
"camel.vault.hashicorp.secret-id", "a-secret-id")));
try {
assertEquals("hashicorp", function.getName());
} finally {
// the client logs in at its first request, so there is no token to revoke
parser.release(function);
}
}

@Test
void anIncompleteLoginFailsAsACamelExceptionNamingTheOptions() {
RuntimeCamelException thrown = assertThrows(RuntimeCamelException.class,
() -> parser.createPropertiesFunction(environmentWith(Map.of(
"camel.vault.hashicorp.host", "127.0.0.1",
"camel.vault.hashicorp.port", "8200",
"camel.vault.hashicorp.scheme", "http",
"camel.vault.hashicorp.authentication", "approle",
"camel.vault.hashicorp.role-id", "a-role-id"))));

assertTrue(thrown.getMessage().contains("secret ID"),
"the failure must name what the login misses, was: " + thrown.getMessage());
assertTrue(thrown.getMessage().contains("camel.vault.hashicorp"),
"the failure must name the options, was: " + thrown.getMessage());
assertInstanceOf(IllegalArgumentException.class, thrown.getCause());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public abstract class AbstractEarlyResolutionPropertiesParser
*/
protected abstract PropertiesFunction createPropertiesFunction(ConfigurableEnvironment environment);

/**
* Releases what the properties function holds, such as the login of its client, once the properties are resolved,
* also when they could not be. Does nothing by default.
*/
protected void release(PropertiesFunction propertiesFunction) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, fine as a follow-up JIRA: only the hashicorp parser overrides release. The AWS parser (SecretsManagerClient, with its HTTP connection pool) and the GCP parser (SecretManagerServiceClient, with its gRPC channel and threads) still never close the clients of the early resolution. With this hook in place they could close them too, or the base class could close a function that is AutoCloseable.

Claude Code (Opus 5.5) on behalf of Federico Mariani

}

/**
* Wording used in diagnostics to name where a value was being resolved from.
*/
Expand All @@ -89,12 +96,20 @@ public final void onApplicationEvent(ApplicationEnvironmentPreparedEvent event)
return;
}

PropertiesFunction propertiesFunction = createPropertiesFunction(environment);
try {
resolve(environment, propertiesFunction);
} finally {
release(propertiesFunction);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

release can hide the resolution error. If resolve throws (for example the aggregated "Failed to resolve N property placeholder(s)" error) and release throws too, the exception from release replaces it and the list of unresolved keys is lost. Now that release is a protected extension point, catching and logging its exception, or adding it with addSuppressed, would keep the original error.

Claude Code (Opus 5.5) on behalf of Federico Mariani

}
}

private void resolve(ConfigurableEnvironment environment, PropertiesFunction propertiesFunction) {
// an unresolved placeholder would otherwise stay in the property value and become the effective
// secret, so resolution failures abort startup unless the operator opts back into the old behaviour
final boolean ignoreResolutionFailures
= Boolean.parseBoolean(environment.getProperty(IGNORE_RESOLUTION_FAILURES));

PropertiesFunction propertiesFunction = createPropertiesFunction(environment);
LOG.debug("Early resolving properties using the {} function", propertiesFunction.getName());

final String prefix = "{{" + propertiesFunction.getName() + ":";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public String apply(String remainder) {
private static final class TestParser extends AbstractEarlyResolutionPropertiesParser {
private final StubPropertiesFunction function;
private int factoryCalls;
private int releaseCalls;

TestParser(StubPropertiesFunction function) {
this.function = function;
Expand All @@ -111,6 +112,12 @@ protected PropertiesFunction createPropertiesFunction(ConfigurableEnvironment en
factoryCalls++;
return function;
}

@Override
protected void release(PropertiesFunction propertiesFunction) {
assertEquals(function, propertiesFunction);
releaseCalls++;
}
}

private static ApplicationEnvironmentPreparedEvent eventFor(ConfigurableEnvironment environment) {
Expand Down Expand Up @@ -144,6 +151,27 @@ public void guardDisabledDoesNotBuildTheClient() {
"no override property source may be added when early resolution is disabled");
}

@Test
void releasesThePropertiesFunctionOnceResolved() {
RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true", "my.secret", "{{test:db}}"));
TestParser parser = new TestParser(new StubPropertiesFunction(Map.of("db", "pazzword"), Set.of()));

parser.onApplicationEvent(eventFor(environment));

assertEquals("pazzword", environment.getProperty("my.secret"));
assertEquals(1, parser.releaseCalls, "the function is released once the properties are resolved");
}

@Test
void releasesThePropertiesFunctionAlsoWhenResolutionFails() {
RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true", "my.secret", "{{test:missing}}"));
TestParser parser = new TestParser(new StubPropertiesFunction(Map.of(), Set.of("missing")));

assertThrows(RuntimeCamelException.class, () -> parser.onApplicationEvent(eventFor(environment)));

assertEquals(1, parser.releaseCalls, "the function is released also when startup is aborted");
}

@Test
public void guardEnabledBuildsTheClientAndAddsTheOverrideSource() {
RecordingEnvironment environment = environmentWith(Map.of(GUARD, "true"));
Expand Down
Loading