This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.22.x by this push:
new 7e20115f87cb [backport camel-4.22.x] CAMEL-25584: camel-lra - only
join LRAs hosted by the configured LRA coordinator (#27769)
7e20115f87cb is described below
commit 7e20115f87cbec5adb58312de6235f9f50fe3049
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Oct 13 16:36:31 2026 +0200
[backport camel-4.22.x] CAMEL-25584: camel-lra - only join LRAs hosted by
the configured LRA coordinator (#27769)
Backport of #27725.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../org/apache/camel/catalog/docs/main.adoc | 3 +-
.../org/apache/camel/catalog/docs/saga-eip.adoc | 5 +
.../main/camel-main-configuration-metadata.json | 1 +
.../service/lra/LRASagaServiceConfigurer.java | 6 +
.../apache/camel/service/lra/LRASagaService.java | 123 +++++++++++++++++-
.../camel/service/lra/LRAPropagatedSagaTest.java | 129 +++++++++++++++++++
.../lra/LRASagaServiceAllowedCoordinatorTest.java | 141 +++++++++++++++++++++
.../src/main/docs/modules/eips/pages/saga-eip.adoc | 5 +
.../main/LraConfigurationPropertiesConfigurer.java | 7 +
.../camel-main-configuration-metadata.json | 1 +
core/camel-main/src/main/docs/main.adoc | 3 +-
.../camel/main/LraConfigurationProperties.java | 24 ++++
12 files changed, 445 insertions(+), 3 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
index 43ca85abfd57..2ed417ded36c 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
@@ -725,11 +725,12 @@ The camel.resilience4j supports 25 options, which are
listed below.
=== Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 5 options, which are listed below.
+The camel.lra supports 6 options, which are listed below.
[width="100%",cols="2,5,^1,2",options="header"]
|===
| Name | Description | Default | Type
+| `camel.lra.allowedCoordinatorUrls` | Comma-separated URLs, including the
context path, of further LRA coordinators whose LRAs may be joined, completed
or compensated. By default, only an LRA hosted by the configured coordinator is
accepted. Use it when participants reach the same coordinator under different
URLs, for example with different host names. | | String
| `camel.lra.coordinatorContextPath` | The context-path for the LRA
coordinator. Is default /lra-coordinator | /lra-coordinator | String
| `camel.lra.coordinatorUrl` | The URL for the LRA coordinator service that
orchestrates the transactions | | String
| `camel.lra.enabled` | To enable Saga LRA | false | boolean
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
index 124128079450..8e2ecd7671a0 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
@@ -93,6 +93,11 @@ Users can follow instructions present on the Narayana
website to *start up a rem
The URL of the LRA coordinator is a required parameter of the Camel LRA
service. The Camel application and the LRA service communicate using the HTTP
protocol.
+A saga started by another participant is joined when the message carries its
LRA in the `Long-Running-Action` header.
+The LRA Saga Service only joins, completes or compensates an LRA hosted by the
configured coordinator: a `Long-Running-Action` with any other URL fails the
exchange.
+When the participants reach the coordinator under different URLs, for example
with different host names, list the other coordinator URLs, including the
context path, in the `allowedCoordinatorUrls` option
(`camel.lra.allowedCoordinatorUrls` with Camel Main).
+This also applies to the LRAs the service starts itself: when the coordinator
returns LRA URLs under another base URL than `coordinatorUrl`, for example
behind a proxy, list that base URL as well.
+
To use the LRA Saga service, maven users will need to add the following
dependency to their `pom.xml`:
[source,xml]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index 5268c0fa3730..dcba49de3313 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -284,6 +284,7 @@
{ "name": "camel.health.producersEnabled", "required": false,
"description": "Whether producers health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
{ "name": "camel.health.registryEnabled", "required": false,
"description": "Whether registry health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.health.routesEnabled", "required": false, "description":
"Whether routes health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
+ { "name": "camel.lra.allowedCoordinatorUrls", "required": false,
"description": "Comma-separated URLs, including the context path, of further
LRA coordinators whose LRAs may be joined, completed or compensated. By
default, only an LRA hosted by the configured coordinator is accepted. Use it
when participants reach the same coordinator under different URLs, for example
with different host names.", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
{ "name": "camel.lra.coordinatorContextPath", "required": false,
"description": "The context-path for the LRA coordinator. Is default
\/lra-coordinator", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string",
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret":
false },
{ "name": "camel.lra.coordinatorUrl", "required": false, "description":
"The URL for the LRA coordinator service that orchestrates the transactions",
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type":
"string", "javaType": "java.lang.String", "secret": false },
{ "name": "camel.lra.enabled", "required": false, "description": "To
enable Saga LRA", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean",
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
index 53d34137e6c8..cba17a6be24b 100644
---
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
+++
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
@@ -23,6 +23,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
org.apache.camel.service.lra.LRASagaService target =
(org.apache.camel.service.lra.LRASagaService) obj;
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls":
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class,
value)); return true;
case "camelcontext":
case "camelContext": target.setCamelContext(property(camelContext,
org.apache.camel.CamelContext.class, value)); return true;
case "coordinatorcontextpath":
@@ -40,6 +42,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
@Override
public Class<?> getOptionType(String name, boolean ignoreCase) {
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls": return java.lang.String.class;
case "camelcontext":
case "camelContext": return org.apache.camel.CamelContext.class;
case "coordinatorcontextpath":
@@ -58,6 +62,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
org.apache.camel.service.lra.LRASagaService target =
(org.apache.camel.service.lra.LRASagaService) obj;
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls": return
target.getAllowedCoordinatorUrls();
case "camelcontext":
case "camelContext": return target.getCamelContext();
case "coordinatorcontextpath":
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
index 841b365616aa..7ccc1bfdae08 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
@@ -16,10 +16,15 @@
*/
package org.apache.camel.service.lra;
+import java.net.MalformedURLException;
import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@@ -38,6 +43,7 @@ import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.annotations.JdkService;
import org.apache.camel.support.LifecycleStrategySupport;
import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.ObjectHelper;
/**
* A Camel saga service based on Microprofile LRA
(https://github.com/eclipse/microprofile-lra).
@@ -65,6 +71,8 @@ public class LRASagaService extends ServiceSupport implements
StaticService, Cam
private String localParticipantUrl;
@Metadata
private String localParticipantContextPath =
LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+ @Metadata(label = "security")
+ private String allowedCoordinatorUrls;
public LRASagaService() {
}
@@ -84,17 +92,107 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
return true;
}
+ /**
+ * Returns the coordinator of an LRA, which is joined, completed or
compensated by calling its URL. Only an LRA
+ * hosted by the configured coordinator, or by one of the allowed
coordinator URLs, is accepted.
+ */
@Override
public CompletableFuture<CamelSagaCoordinator> getSaga(String id) {
CompletableFuture<CamelSagaCoordinator> coordinator;
try {
- coordinator = CompletableFuture.completedFuture(new
LRASagaCoordinator(this, URI.create(id).toURL()));
+ URI lra = URI.create(id);
+ if (!isHostedByAllowedCoordinator(lra)) {
+ throw new IllegalArgumentException(
+ "The Long-Running-Action " + id + " is not hosted by
the LRA coordinator configured on the LRA"
+ + " saga service. If it is
a trusted coordinator, add its URL to"
+ + "
allowedCoordinatorUrls.");
+ }
+ coordinator = CompletableFuture.completedFuture(new
LRASagaCoordinator(this, lra.toURL()));
} catch (Exception ex) {
coordinator = CompletableFuture.failedFuture(ex);
}
return coordinator;
}
+ private boolean isHostedByAllowedCoordinator(URI lra) {
+ for (URI coordinator : allowedCoordinators()) {
+ if (isHostedBy(lra, coordinator)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The URLs of the coordinators whose LRAs are accepted: the configured
coordinator, and the allowed coordinator
+ * URLs.
+ */
+ private List<URI> allowedCoordinators() {
+ List<URI> coordinators = new ArrayList<>();
+ coordinators.add(URI.create(new
LRAUrlBuilder().host(coordinatorUrl).path(coordinatorContextPath).build()));
+ coordinators.addAll(additionalCoordinators());
+ return coordinators;
+ }
+
+ private List<URI> additionalCoordinators() {
+ List<URI> coordinators = new ArrayList<>();
+ if (ObjectHelper.isNotEmpty(allowedCoordinatorUrls)) {
+ for (String url : allowedCoordinatorUrls.split(",")) {
+ if (!url.isBlank()) {
+ coordinators.add(URI.create(url.trim()));
+ }
+ }
+ }
+ return coordinators;
+ }
+
+ /**
+ * Whether the LRA URL is under the coordinator URL: same scheme, host and
port, no user info, and a path under the
+ * path of the coordinator.
+ */
+ private static boolean isHostedBy(URI lra, URI coordinator) {
+ // host and port are read through URL, which also takes host names
that URI does not, such as lra_coordinator
+ URL lraUrl = toUrl(lra);
+ URL coordinatorUrl = toUrl(coordinator);
+ if (lraUrl == null || coordinatorUrl == null || lraUrl.getUserInfo()
!= null || lraUrl.getHost().isEmpty()) {
+ return false;
+ }
+ String rawPath = lra.getRawPath();
+ if (rawPath == null || hasEncodedOrParameterSegment(rawPath)) {
+ return false;
+ }
+ String lraPath = lra.normalize().getRawPath();
+ String coordinatorPath =
Objects.toString(coordinator.normalize().getRawPath(), "");
+ if (!coordinatorPath.endsWith("/")) {
+ coordinatorPath += "/";
+ }
+ return
lraUrl.getProtocol().equalsIgnoreCase(coordinatorUrl.getProtocol())
+ && lraUrl.getHost().equalsIgnoreCase(coordinatorUrl.getHost())
+ && port(lraUrl) == port(coordinatorUrl)
+ && lraPath.startsWith(coordinatorPath);
+ }
+
+ /**
+ * Whether the path holds a path parameter, or an encoded dot, slash or
backslash. A server may resolve them as dot
+ * segments after this check, so such a path could leave the path of the
coordinator.
+ */
+ private static boolean hasEncodedOrParameterSegment(String rawPath) {
+ String path = rawPath.toLowerCase(Locale.ROOT);
+ return path.contains(";") || path.contains("%2e") ||
path.contains("%2f") || path.contains("%5c");
+ }
+
+ private static URL toUrl(URI uri) {
+ try {
+ return uri.isAbsolute() ? uri.toURL() : null;
+ } catch (MalformedURLException | IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private static int port(URL url) {
+ return url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
+ }
+
@Override
public void registerStep(CamelSagaStep step) {
step.getCompensation().map(Endpoint::getEndpointUri).ifPresent(compensationURIs::add);
@@ -139,6 +237,15 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
if (localParticipantUrl == null) {
throw new IllegalStateException("localParticipantUrl must be
configured on the LRA saga service");
}
+ for (URI coordinator : additionalCoordinators()) {
+ URL url = toUrl(coordinator);
+ if (url == null || url.getHost().isEmpty()
+ || !("http".equals(url.getProtocol()) ||
"https".equals(url.getProtocol()))) {
+ throw new IllegalArgumentException(
+ "allowedCoordinatorUrls must hold http or https
coordinator URLs with a host name, but no such"
+ + " URL could be read from:
" + coordinator);
+ }
+ }
if (this.executorService == null) {
this.executorService = camelContext.getExecutorServiceManager()
@@ -253,6 +360,20 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
this.localParticipantContextPath = localParticipantContextPath;
}
+ @ManagedAttribute(description = "Allowed coordinator URLs")
+ public String getAllowedCoordinatorUrls() {
+ return allowedCoordinatorUrls;
+ }
+
+ /**
+ * Comma-separated URLs, including the context path, of further LRA
coordinators whose LRAs may be joined, completed
+ * or compensated. By default, only an LRA hosted by the configured
coordinator is accepted. Use it when
+ * participants reach the same coordinator under different URLs, for
example with different host names.
+ */
+ public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+ this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+ }
+
public Set<String> getRegisteredCompensationURIs() {
return compensationURIs;
}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
new file mode 100644
index 000000000000..af1899f54075
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.service.lra;
+
+import java.net.URL;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests that a saga step joins an LRA propagated in the Long-Running-Action
header only when the LRA is hosted by the
+ * configured coordinator.
+ */
+class LRAPropagatedSagaTest extends CamelTestSupport {
+
+ private static final String COORDINATOR_URL =
"http://lra-coordinator:8080";
+
+ @RegisterExtension
+ AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+ private final LRAClient client = Mockito.mock(LRAClient.class);
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+
+ LRASagaService sagaService = new MockClientLRASagaService(client);
+ sagaService.setCoordinatorUrl(COORDINATOR_URL);
+ sagaService.setLocalParticipantUrl("http://localhost:" +
port.getPort());
+ context.addService(sagaService);
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ restConfiguration().port(port.getPort());
+ }
+ });
+ return context;
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:step")
+ .saga().propagation(SagaPropagation.SUPPORTS)
+ .compensation("mock:compensate")
+ .to("mock:step");
+ }
+ };
+ }
+
+ @Test
+ void joinsAnLraOfTheConfiguredCoordinator() throws Exception {
+ Mockito.when(client.join(Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+ MockEndpoint step = getMockEndpoint("mock:step");
+ step.expectedMessageCount(1);
+
+ Exchange exchange = send(COORDINATOR_URL + "/lra-coordinator/0_1");
+
+ assertNull(exchange.getException());
+ step.assertIsSatisfied();
+ ArgumentCaptor<URL> joined = ArgumentCaptor.forClass(URL.class);
+ Mockito.verify(client).join(joined.capture(), Mockito.any(),
Mockito.any());
+ assertEquals(COORDINATOR_URL + "/lra-coordinator/0_1",
joined.getValue().toString());
+ }
+
+ @Test
+ void doesNotJoinAnLraOfAnotherCoordinator() throws Exception {
+ MockEndpoint step = getMockEndpoint("mock:step");
+ step.expectedMessageCount(0);
+
+ Exchange exchange = send("http://elsewhere:8080/lra-coordinator/0_1");
+
+ assertInstanceOf(IllegalArgumentException.class,
exchange.getException());
+ step.assertIsSatisfied();
+ Mockito.verifyNoInteractions(client);
+ }
+
+ private Exchange send(String lra) {
+ return template.send("direct:step", e ->
e.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, lra));
+ }
+
+ // a named class, as the thread pool of the service is named after its
class
+ private static class MockClientLRASagaService extends LRASagaService {
+
+ private final LRAClient client;
+
+ MockClientLRASagaService(LRAClient client) {
+ this.client = client;
+ }
+
+ @Override
+ protected LRAClient createLRAClient() {
+ return client;
+ }
+ }
+}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
new file mode 100644
index 000000000000..f8a1dbea5a78
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.service.lra;
+
+import java.util.concurrent.CompletionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests which LRAs the LRA saga service accepts to join, complete or
compensate.
+ */
+class LRASagaServiceAllowedCoordinatorTest {
+
+ private final LRASagaService sagaService = new LRASagaService();
+
+ @BeforeEach
+ void configure() {
+ sagaService.setCoordinatorUrl("http://lra-coordinator:8080");
+ sagaService.setLocalParticipantUrl("http://participant:8080");
+ }
+
+ @Test
+ void acceptsAnLraOfTheConfiguredCoordinator() {
+
assertAccepted("http://lra-coordinator:8080/lra-coordinator/0_ffff0a1e1f05_1");
+ }
+
+ @Test
+ void comparesSchemeAndHostIgnoringCase() {
+ assertAccepted("HTTP://LRA-Coordinator:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void treatsTheDefaultPortLikeAnExplicitOne() {
+ sagaService.setCoordinatorUrl("http://lra-coordinator");
+
+ assertAccepted("http://lra-coordinator:80/lra-coordinator/0_1");
+ }
+
+ @Test
+ void honoursTheCoordinatorContextPath() {
+ sagaService.setCoordinatorContextPath("/api/v1/lra-coordinator");
+
+
assertAccepted("http://lra-coordinator:8080/api/v1/lra-coordinator/0_1");
+ assertRejected("http://lra-coordinator:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void rejectsAnLraOfAnotherHost() {
+ assertRejected("http://elsewhere:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void rejectsAnLraOfAnotherPortOrScheme() {
+ assertRejected("http://lra-coordinator:9090/lra-coordinator/0_1");
+ assertRejected("https://lra-coordinator:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void rejectsAPathOutsideTheCoordinatorContextPath() {
+ assertRejected("http://lra-coordinator:8080/other/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator-other/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/../other/0_1");
+ }
+
+ @Test
+ void rejectsEncodedOrParameterDotSegments() {
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/%2e%2e/other/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/%2E%2E/other/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/..;/other/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/..%2fother/0_1");
+
assertRejected("http://lra-coordinator:8080/lra-coordinator/..%5Cother/0_1");
+ }
+
+ @Test
+ void acceptsAnLraOfACoordinatorWithAnUnderscoreInItsHostName() {
+ sagaService.setCoordinatorUrl("http://lra_coordinator:8080");
+
+ assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1");
+ assertRejected("http://other_coordinator:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void rejectsAnLraWithUserInfo() {
+ assertRejected("http://user@lra-coordinator:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void acceptsAnLraOfAnAllowedCoordinatorUrl() {
+ sagaService.setAllowedCoordinatorUrls(
+ "http://lra-coordinator.lra.svc:8080/lra-coordinator,
https://lra.example.com/coordinator,"
+ + "
http://lra_coordinator:8080/lra-coordinator");
+
+
assertAccepted("http://lra-coordinator.lra.svc:8080/lra-coordinator/0_1");
+ assertAccepted("https://lra.example.com/coordinator/0_1");
+ assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1");
+ assertRejected("http://elsewhere:8080/lra-coordinator/0_1");
+ }
+
+ @Test
+ void failsToStartWithARelativeAllowedCoordinatorUrl() {
+
sagaService.setAllowedCoordinatorUrls("lra-coordinator/lra-coordinator");
+
+ assertThrows(IllegalArgumentException.class, sagaService::doStart);
+ }
+
+ @Test
+ void failsToStartWithAnAllowedCoordinatorUrlThatIsNotHttp() {
+
sagaService.setAllowedCoordinatorUrls("ftp://lra-coordinator/lra-coordinator");
+
+ assertThrows(IllegalArgumentException.class, sagaService::doStart);
+ }
+
+ private void assertAccepted(String lra) {
+ assertNotNull(sagaService.getSaga(lra).join(), lra + " must be
accepted");
+ }
+
+ private void assertRejected(String lra) {
+ CompletionException e = assertThrows(CompletionException.class, () ->
sagaService.getSaga(lra).join(),
+ lra + " must be rejected");
+ assertInstanceOf(IllegalArgumentException.class, e.getCause());
+ }
+}
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
index 124128079450..8e2ecd7671a0 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
@@ -93,6 +93,11 @@ Users can follow instructions present on the Narayana
website to *start up a rem
The URL of the LRA coordinator is a required parameter of the Camel LRA
service. The Camel application and the LRA service communicate using the HTTP
protocol.
+A saga started by another participant is joined when the message carries its
LRA in the `Long-Running-Action` header.
+The LRA Saga Service only joins, completes or compensates an LRA hosted by the
configured coordinator: a `Long-Running-Action` with any other URL fails the
exchange.
+When the participants reach the coordinator under different URLs, for example
with different host names, list the other coordinator URLs, including the
context path, in the `allowedCoordinatorUrls` option
(`camel.lra.allowedCoordinatorUrls` with Camel Main).
+This also applies to the LRAs the service starts itself: when the coordinator
returns LRA URLs under another base URL than `coordinatorUrl`, for example
behind a proxy, list that base URL as well.
+
To use the LRA Saga service, maven users will need to add the following
dependency to their `pom.xml`:
[source,xml]
diff --git
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
index badb2b23ea60..3bdb1349e7c1 100644
---
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
+++
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
@@ -22,6 +22,7 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
private static final Map<String, Object> ALL_OPTIONS;
static {
Map<String, Object> map = new CaseInsensitiveMap();
+ map.put("AllowedCoordinatorUrls", java.lang.String.class);
map.put("CoordinatorContextPath", java.lang.String.class);
map.put("CoordinatorUrl", java.lang.String.class);
map.put("Enabled", boolean.class);
@@ -34,6 +35,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
org.apache.camel.main.LraConfigurationProperties target =
(org.apache.camel.main.LraConfigurationProperties) obj;
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls":
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class,
value)); return true;
case "coordinatorcontextpath":
case "coordinatorContextPath":
target.setCoordinatorContextPath(property(camelContext, java.lang.String.class,
value)); return true;
case "coordinatorurl":
@@ -55,6 +58,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
@Override
public Class<?> getOptionType(String name, boolean ignoreCase) {
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls": return java.lang.String.class;
case "coordinatorcontextpath":
case "coordinatorContextPath": return java.lang.String.class;
case "coordinatorurl":
@@ -72,6 +77,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
org.apache.camel.main.LraConfigurationProperties target =
(org.apache.camel.main.LraConfigurationProperties) obj;
switch (ignoreCase ? name.toLowerCase() : name) {
+ case "allowedcoordinatorurls":
+ case "allowedCoordinatorUrls": return
target.getAllowedCoordinatorUrls();
case "coordinatorcontextpath":
case "coordinatorContextPath": return
target.getCoordinatorContextPath();
case "coordinatorurl":
diff --git
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index 5268c0fa3730..dcba49de3313 100644
---
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -284,6 +284,7 @@
{ "name": "camel.health.producersEnabled", "required": false,
"description": "Whether producers health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
{ "name": "camel.health.registryEnabled", "required": false,
"description": "Whether registry health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.health.routesEnabled", "required": false, "description":
"Whether routes health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
+ { "name": "camel.lra.allowedCoordinatorUrls", "required": false,
"description": "Comma-separated URLs, including the context path, of further
LRA coordinators whose LRAs may be joined, completed or compensated. By
default, only an LRA hosted by the configured coordinator is accepted. Use it
when participants reach the same coordinator under different URLs, for example
with different host names.", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
{ "name": "camel.lra.coordinatorContextPath", "required": false,
"description": "The context-path for the LRA coordinator. Is default
\/lra-coordinator", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string",
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret":
false },
{ "name": "camel.lra.coordinatorUrl", "required": false, "description":
"The URL for the LRA coordinator service that orchestrates the transactions",
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type":
"string", "javaType": "java.lang.String", "secret": false },
{ "name": "camel.lra.enabled", "required": false, "description": "To
enable Saga LRA", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean",
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git a/core/camel-main/src/main/docs/main.adoc
b/core/camel-main/src/main/docs/main.adoc
index 43ca85abfd57..2ed417ded36c 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -725,11 +725,12 @@ The camel.resilience4j supports 25 options, which are
listed below.
=== Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 5 options, which are listed below.
+The camel.lra supports 6 options, which are listed below.
[width="100%",cols="2,5,^1,2",options="header"]
|===
| Name | Description | Default | Type
+| `camel.lra.allowedCoordinatorUrls` | Comma-separated URLs, including the
context path, of further LRA coordinators whose LRAs may be joined, completed
or compensated. By default, only an LRA hosted by the configured coordinator is
accepted. Use it when participants reach the same coordinator under different
URLs, for example with different host names. | | String
| `camel.lra.coordinatorContextPath` | The context-path for the LRA
coordinator. Is default /lra-coordinator | /lra-coordinator | String
| `camel.lra.coordinatorUrl` | The URL for the LRA coordinator service that
orchestrates the transactions | | String
| `camel.lra.enabled` | To enable Saga LRA | false | boolean
diff --git
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
index 3c91843d31c7..6ae967f1a0a8 100644
---
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
+++
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
@@ -36,6 +36,8 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
private String localParticipantUrl;
@Metadata(defaultValue = "/lra-participant")
private String localParticipantContextPath = "/lra-participant";
+ @Metadata(label = "security")
+ private String allowedCoordinatorUrls;
public LraConfigurationProperties(MainConfigurationProperties parent) {
this.parent = parent;
@@ -109,6 +111,19 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
this.localParticipantContextPath = localParticipantContextPath;
}
+ public String getAllowedCoordinatorUrls() {
+ return allowedCoordinatorUrls;
+ }
+
+ /**
+ * Comma-separated URLs, including the context path, of further LRA
coordinators whose LRAs may be joined, completed
+ * or compensated. By default, only an LRA hosted by the configured
coordinator is accepted. Use it when
+ * participants reach the same coordinator under different URLs, for
example with different host names.
+ */
+ public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+ this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+ }
+
/**
* The URL for the LRA coordinator service that orchestrates the
transactions
*/
@@ -149,4 +164,13 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
return this;
}
+ /**
+ * Comma-separated URLs, including the context path, of further LRA
coordinators whose LRAs may be joined, completed
+ * or compensated.
+ */
+ public LraConfigurationProperties withAllowedCoordinatorUrls(String
allowedCoordinatorUrls) {
+ this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+ return this;
+ }
+
}