This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 0a215c9943da CAMEL-26792: camel-jbang - a chaos experiment says why a
route cannot be fed, and warns when the feed goes to a real system (#29519)
0a215c9943da is described below
commit 0a215c9943da1aa26c2fea4e9076d9178f87dfce
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 01:17:06 2026 +0100
CAMEL-26792: camel-jbang - a chaos experiment says why a route cannot be
fed, and warns when the feed goes to a real system (#29519)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-managing.adoc | 7 ++
.../core/commands/ai/ChaosExplorePlaybook.java | 3 +-
.../dsl/jbang/core/common/ChaosExperimentRun.java | 137 ++++++++++++++++++---
.../jbang/core/common/ChaosExperimentRunTest.java | 67 ++++++++++
4 files changed, 193 insertions(+), 21 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index e744bbfe3775..f40dcd67cbc1 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -942,6 +942,13 @@ The verdict is `FAIL` when an expectation is unmet (exit
code 1), `PASS` when al
wait was up before an expectation was decided. `--feed` takes the endpoint, or
the id of the route, as the `feed:` line
of `chaos-suggest` has it. `camel get chaos --report` shows the exploration
with its experiments.
+The messages of `--feed` are sent to the endpoint the route starts from, so
they reach a route that starts from
+`direct`, `seda`, a stub (`camel run --stub=kafka`) or a broker. An experiment
refuses a feed that cannot reach its
+route, with the reason: a route that starts from a timer, scheduler, cron or
quartz makes its own messages (leave
+`--feed` out), and a route that serves HTTP, or starts from a component that
only consumes, cannot be fed this way. A
+feed to a remote system that is not stubbed, such as a Kafka broker, has a
warning, as the messages go to that system:
+use a local one (`camel infra run kafka`), or stub it.
+
`camel cmd chaos-explore` lets the AI of `camel ask` do it all: let there be
chaos. It starts the exploration with the
budget, and the AI reads the suggestions, runs one experiment per call, reads
each verdict, and goes on while there is
time. It asks nothing on the way, as starting the command is the consent, and
it never changes the routes. When it is
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
index 181f53d49339..5a06f0074ca7 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
@@ -90,7 +90,8 @@ public final class ChaosExplorePlaybook {
expects it, such as JSON), repeat (such as 20) and interval
(such as 100..300); leave feed out \
only when the route feeds itself (kind self). \
Routes fed over http or from files cannot be fed by an
experiment yet: try them only when they \
- feed themselves
+ feed themselves. An experiment refuses a feed that cannot
reach its route, with the reason and \
+ what to do instead, and warns when the feed goes to a real
system that is not stubbed
- a rule at=dlq fails the dead letter channel, so it fires
only when a message goes there: use \
the combined suggestions, which add the fault that sends
messages there too. A message the \
dead letter channel did not deliver is handled there, so it
does not fail in its route: the \
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
index 4be77ccc7705..03f7bceb964d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
@@ -16,12 +16,16 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.util.TimeUtils;
import org.apache.camel.util.json.JsonObject;
@@ -135,9 +139,12 @@ public final class ChaosExperimentRun {
static JsonObject run(
Integration integration, String hypothesis, String rules, Feed
feed, long wait, String note, Clock clock)
throws InterruptedException {
+ // the status is read once, before the experiment, for the feed; the
exploration at the end reads it again,
+ // for the state after the experiment
+ JsonObject status = feed != null ? integration.status() : null;
if (feed != null) {
// a feed that reaches no route makes an experiment without
messages, whose verdict says nothing
- String why = checkFeed(integration.status(), feed.endpoint());
+ String why = checkFeed(status, feed.endpoint());
if (why != null) {
throw new IllegalArgumentException(why);
}
@@ -154,8 +161,18 @@ public final class ChaosExperimentRun {
root.put("rule", rules);
});
int number = begun.getIntegerOrDefault("number", 0);
- // a rule that matches nothing in the integration now (often a typo)
- List<Object> warnings = begun.getCollection("warnings");
+ // a rule that matches nothing in the integration now (often a typo),
and a feed to a real system
+ List<Object> warnings = new ArrayList<>();
+ List<Object> ruleWarnings = begun.getCollection("warnings");
+ if (ruleWarnings != null) {
+ warnings.addAll(ruleWarnings);
+ }
+ if (feed != null) {
+ String real = feedWarning(status, feed.endpoint());
+ if (real != null) {
+ warnings.add(real);
+ }
+ }
JsonObject traffic = null;
long waited = 0;
try {
@@ -187,7 +204,7 @@ public final class ChaosExperimentRun {
answer.put("traffic", traffic);
}
answer.put("waited", waited);
- if (warnings != null && !warnings.isEmpty()) {
+ if (!warnings.isEmpty()) {
answer.put("warnings", warnings);
}
JsonObject exploration = exploration(integration.status());
@@ -256,35 +273,115 @@ public final class ChaosExperimentRun {
}
/**
- * Why the feed reaches no route of the integration, or null when it does
(or the status does not tell): the feed is
- * the id of a route, a pattern, or the endpoint a route starts from, as
camel cmd send finds its target.
+ * Why the feed cannot reach a route of the integration, or null when it
does (or the status does not tell): the
+ * feed is the id of a route, a pattern, or the endpoint a route starts
from, as camel cmd send finds its target. It
+ * is sent to that endpoint with a producer, which reaches the route for
direct, seda, a stub or a broker, but not
+ * for a route that makes its own messages (a timer), serves HTTP, or
starts from a component that only consumes
+ * (CAMEL-26792).
*/
public static String checkFeed(JsonObject status, String feed) {
List<Object> routes = status != null ? status.getCollection("routes")
: null;
if (routes == null || routes.isEmpty() || feed.endsWith("*")) {
return null;
}
- String endpoint = normalize(feed);
+ JsonObject route = route(routes, feed);
+ if (route != null) {
+ return cannotFeed(route.getString("routeId"),
route.getString("from"));
+ }
StringJoiner inputs = new StringJoiner(", ");
for (Object o : routes) {
- if (!(o instanceof JsonObject route)) {
- continue;
+ if (o instanceof JsonObject r) {
+ inputs.add(r.getString("routeId") + " (" + r.getString("from")
+ ")");
}
- String id = route.getString("routeId");
- String from = route.getString("from");
- if (feed.equals(id)) {
- return null;
+ }
+ return "The feed " + feed + " is not where a route starts, so no
message would reach the integration: give the"
+ + " id of a route, or the endpoint it starts from: " + inputs;
+ }
+
+ /**
+ * A warning when the feed goes to a real remote system, as the route it
reaches starts from one that is not stubbed
+ * (a Kafka broker, a JMS queue), or null. Such a feed is fine for a local
broker (camel infra run).
+ */
+ public static String feedWarning(JsonObject status, String feed) {
+ List<Object> routes = status != null ? status.getCollection("routes")
: null;
+ JsonObject route = routes != null && !feed.endsWith("*") ?
route(routes, feed) : null;
+ String from = route != null ? route.getString("from") : null;
+ ComponentModel model = from != null ? componentModel(from) : null;
+ // a file is written to the folder the route polls: not a remote system
+ if (model == null || !model.isRemote() ||
"file".equals(model.getScheme())) {
+ return null;
+ }
+ String id = route.getString("routeId");
+ Boolean stubbed = stubbed(status, id);
+ if (stubbed == null || stubbed) {
+ return null;
+ }
+ String scheme = model.getScheme();
+ return "The feed goes to " + from + ", where route " + id + " starts,
and it is not stubbed: the messages go to"
+ + " the real " + scheme + ". Use a local one (camel infra run "
+ scheme + "), or run the integration"
+ + " with --stub=" + scheme + " so no real system gets them";
+ }
+
+ /** The route the feed reaches: by its id, or by the endpoint it starts
from; null when none. */
+ private static JsonObject route(List<Object> routes, String feed) {
+ String endpoint = normalize(feed);
+ for (Object o : routes) {
+ if (o instanceof JsonObject route) {
+ String from = route.getString("from");
+ if (feed.equals(route.getString("routeId"))
+ || from != null && (normalize(from).equals(endpoint)
|| normalize(from).startsWith(endpoint + "?"))) {
+ return route;
+ }
}
- if (from != null) {
- String input = normalize(from);
- if (input.equals(endpoint) || input.startsWith(endpoint +
"?")) {
- return null;
+ }
+ return null;
+ }
+
+ /** Why a message sent to the endpoint a route starts from does not reach
it, or null when it does. */
+ static String cannotFeed(String route, String from) {
+ ComponentModel model = from != null ? componentModel(from) : null;
+ if (model == null) {
+ return null;
+ }
+ String label = model.getLabel() != null ? model.getLabel() : "";
+ if (model.isConsumerOnly() && label.contains("scheduling")) {
+ return "Route " + route + " starts from " + from + ", which makes
its own messages: leave the feed out, as"
+ + " the traffic of the route comes by itself";
+ }
+ if ("rest".equals(model.getScheme()) || label.contains("http")) {
+ return "Route " + route + " starts from the HTTP endpoint " + from
+ ", which an experiment cannot feed"
+ + " yet: feed a route that calls it, or leave the feed out
when its traffic comes otherwise";
+ }
+ if (model.isConsumerOnly()) {
+ return "Route " + route + " starts from " + from + ", which only
consumes, so an experiment cannot send to"
+ + " it: feed another route, or leave the feed out when its
traffic comes otherwise";
+ }
+ return null;
+ }
+
+ /** Whether the chaos report of the status has the route as stubbed; null
when the report does not have it. */
+ private static Boolean stubbed(JsonObject status, String route) {
+ if (!(status.get("chaos") instanceof JsonObject chaos) ||
!(chaos.get("report") instanceof JsonObject report)) {
+ return null;
+ }
+ List<Object> routes = report.getCollection("routes");
+ if (routes != null) {
+ for (Object o : routes) {
+ if (o instanceof JsonObject r &&
route.equals(r.getString("route"))) {
+ return r.containsKey("stubbedConsumer");
}
}
- inputs.add(id + " (" + from + ")");
}
- return "The feed " + feed + " is not where a route starts, so no
message would reach the integration: give the"
- + " id of a route, or the endpoint it starts from: " + inputs;
+ return null;
+ }
+
+ private static ComponentModel componentModel(String uri) {
+ int colon = uri.indexOf(':');
+ return colon > 0 ? Catalog.INSTANCE.componentModel(uri.substring(0,
colon)) : null;
+ }
+
+ private static final class Catalog {
+ static final CamelCatalog INSTANCE = new DefaultCamelCatalog(true);
}
// kafka://refunds and kafka:refunds are the same endpoint
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
index e0b558c1cbb6..8df8c2737a6a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
@@ -228,6 +228,73 @@ class ChaosExperimentRunTest {
assertThat(integration.actions).isEmpty();
}
+ @Test
+ void aRouteThatCannotBeFedSaysWhy() {
+ JsonObject status = new JsonObject(
+ Map.of("routes", new JsonArray(
+ List.of(
+ new JsonObject(Map.of("routeId", "orders",
"from", "timer://orders?period=1000")),
+ new JsonObject(Map.of("routeId", "payment",
"from", "platform-http:///payment")),
+ new JsonObject(Map.of("routeId", "api",
"from", "rest:post:/refunds")),
+ new JsonObject(Map.of("routeId", "leader",
"from", "master:lock:direct:start")),
+ new JsonObject(Map.of("routeId", "inbox",
"from", "file://inbox?delete=true")),
+ new JsonObject(Map.of("routeId", "billing",
"from", "seda://billing"))))));
+ integration.statuses.add(status);
+
+ // a route that makes its own messages (CAMEL-26792)
+ assertThat(ChaosExperimentRun.checkFeed(status, "orders")).isEqualTo(
+ "Route orders starts from timer://orders?period=1000, which
makes its own messages: leave the feed out,"
+ +
" as the traffic of the route comes by itself");
+ // a route that serves HTTP, by its id or its endpoint
+ assertThat(ChaosExperimentRun.checkFeed(status, "payment"))
+ .startsWith("Route payment starts from the HTTP endpoint
platform-http:///payment, which an experiment"
+ + " cannot feed yet");
+ assertThat(ChaosExperimentRun.checkFeed(status,
"platform-http:/payment")).startsWith("Route payment");
+ assertThat(ChaosExperimentRun.checkFeed(status,
"api")).startsWith("Route api starts from the HTTP endpoint");
+ // a component that only consumes
+ assertThat(ChaosExperimentRun.checkFeed(status,
"leader")).contains("which only consumes");
+ // a file is written to the folder the route polls, and seda is in
memory
+ assertThat(ChaosExperimentRun.checkFeed(status, "inbox")).isNull();
+ assertThat(ChaosExperimentRun.checkFeed(status, "billing")).isNull();
+
+ // refused before the experiment begins
+ assertThatThrownBy(() -> ChaosExperimentRun.run(integration, null,
RULE,
+ new ChaosExperimentRun.Feed("orders", "O-1", null, 5, null),
60_000, null, clock))
+
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("which makes
its own messages");
+ assertThat(integration.actions).isEmpty();
+ }
+
+ @Test
+ void aFeedToARealSystemIsWarned() {
+ JsonObject report = new JsonObject(
+ Map.of("routes", new JsonArray(
+ List.of(
+ new JsonObject(Map.of("route", "refunds")),
+ new JsonObject(Map.of("route", "returns",
"stubbedConsumer", "kafka")),
+ new JsonObject(Map.of("route", "inbox"))))));
+ JsonObject status = new JsonObject(
+ Map.of(
+ "routes", new JsonArray(
+ List.of(
+ new JsonObject(
+ Map.of("routeId", "refunds",
"from", "kafka://refunds?brokers=localhost:9092")),
+ new JsonObject(Map.of("routeId",
"returns", "from", "kafka://returns")),
+ new JsonObject(Map.of("routeId",
"inbox", "from", "file://inbox")))),
+ "chaos", new JsonObject(Map.of("report", report))));
+
+ // not stubbed: the messages go to the broker (CAMEL-26792)
+ assertThat(ChaosExperimentRun.feedWarning(status,
"refunds")).isEqualTo(
+ "The feed goes to kafka://refunds?brokers=localhost:9092,
where route refunds starts, and it is not"
+
+ " stubbed: the messages go to the real kafka. Use a local one (camel infra"
+
+ " run kafka), or run the integration with --stub=kafka so no real system"
+
+ " gets them");
+ // stubbed, a file, or a report that does not tell: no warning
+ assertThat(ChaosExperimentRun.feedWarning(status, "returns")).isNull();
+ assertThat(ChaosExperimentRun.feedWarning(status, "inbox")).isNull();
+ assertThat(ChaosExperimentRun.feedWarning(new
JsonObject(Map.of("routes", status.get("routes"))), "refunds"))
+ .isNull();
+ }
+
@Test
void aRuleThatMatchesNothingComesWithItsWarning() throws Exception {
integration.shows("PENDING", true);