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 ab269db43a71 CAMEL-26793: camel-jbang - a chaos experiment feeds a
route that serves HTTP with HTTP requests, and counts the status codes the
clients got (#29521)
ab269db43a71 is described below
commit ab269db43a718ca9317d8ed42231f3468f63e90d
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 01:34:50 2026 +0100
CAMEL-26793: camel-jbang - a chaos experiment feeds a route that serves
HTTP with HTTP requests, and counts the status codes the clients got (#29521)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-managing.adoc | 26 +-
.../camel-jbang-cmd-chaos-experiment.adoc | 2 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../action/CamelChaosExperimentAction.java | 24 +-
.../core/commands/ai/ChaosExplorePlaybook.java | 8 +-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 3 +-
.../dsl/jbang/core/common/ChaosExperimentRun.java | 72 ++++-
.../camel/dsl/jbang/core/common/ChaosHttpFeed.java | 355 +++++++++++++++++++++
.../core/commands/action/CamelChaosActionTest.java | 11 +
.../jbang/core/common/ChaosExperimentRunTest.java | 119 ++++++-
.../src/test/resources/golden/cli-help.txt | 4 +-
.../src/test/resources/golden/cli-parse.txt | 51 +++
.../dsl/jbang/core/commands/mcp/RuntimeTools.java | 3 +-
13 files changed, 653 insertions(+), 27 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 f40dcd67cbc1..ade54afe6470 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -945,8 +945,30 @@ of `chaos-suggest` has it. `camel get chaos --report`
shows the exploration with
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:
+`--feed` out), and a route that starts from a component that only consumes
cannot be fed this way.
+
+A route that serves HTTP over the server of Camel (`platform-http`, or the
REST DSL) is fed with HTTP requests to that
+server, on localhost and its port, with the path and method of the route,
`--body` as the request body and `--header`
+as HTTP headers. They are sent while the experiment waits for its verdict, and
its traffic counts the status codes the
+clients got, so a hypothesis such as "a client gets a 503, not a 500" has its
evidence:
+
+[source,bash]
+----
+camel cmd chaos-experiment --hypothesis='a client of payment gets an error,
nothing is lost' \
+ --rule='route=payment error rate=every:4 until=30s
expect=payment:failed<=0 within=20s' \
+ --feed=payment --body='{"orderId": "O-1", "amount": 10}' --repeat=12
--interval=100..300
+----
+
+[source,text]
+----
+ traffic: 5 messages sent to POST http://localhost:8080/payment in 1s,
answered 200 x4, 500 x1 (of 12: the experiment ended first)
+----
+
+For a path with a parameter, `--feed` is the path to call, with an optional
method: `--feed='PUT /orders/42'`. An HTTP
+server with its own address on this machine (netty-http, undertow, jetty) is
fed at that address, called as localhost;
+one on another host is not fed.
+
+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
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-experiment.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-experiment.adoc
index a8793619685d..00cd4f77dc0e 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-experiment.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-experiment.adoc
@@ -21,7 +21,7 @@ camel cmd chaos-experiment [options]
| Option | Description | Default | Type
| `--body` | The body of the messages: text, file:<path>, or file:<folder> for
a message per file | | String
| `--end` | End the open experiment (with --note) instead of running a new one
| | boolean
-| `--feed` | The endpoint, or the id of the route, to send the messages of the
experiment to | | String
+| `--feed` | The endpoint, or the id of the route, to send the messages of the
experiment to; a route that serves HTTP gets HTTP requests, or give the path to
call, such as 'PUT /orders/42' | | String
| `--header` | A header of the messages as key=value; can be given more than
once | | List
| `--hypothesis` | What the experiment tests, in a few words, such as 'orders
survive a payment outage' | | String
| `--interval` | The pause between two messages, such as 200ms, or a range
such as 100..500 to draw each pause at random (at most 60s) | | String
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index f05a290e35fb..5f0350208ef6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType":
"java.lang.String", "type": "string" }, { "names": "--api-type", "description":
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
\"[[apigroup\/]version:]kind:[namespace\/]name\", plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": " [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "How [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosExperimentAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosExperimentAction.java
index 91eda0f15abe..08fe673bce9b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosExperimentAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosExperimentAction.java
@@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.commands.action;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
+import java.util.StringJoiner;
+import java.util.TreeMap;
import java.util.function.Consumer;
import org.apache.camel.cli.connector.RepeatedSend;
@@ -76,7 +78,9 @@ public class CamelChaosExperimentAction extends
CamelChaosAction {
List<String> rules;
@CommandLine.Option(names = { "--feed" },
- description = "The endpoint, or the id of the route,
to send the messages of the experiment to")
+ description = "The endpoint, or the id of the route,
to send the messages of the experiment to;"
+ + " a route that serves HTTP gets HTTP
requests, or give the path to call, such as"
+ + " 'PUT /orders/42'")
String feed;
@CommandLine.Option(names = { "--body" },
@@ -229,9 +233,21 @@ public class CamelChaosExperimentAction extends
CamelChaosAction {
if (traffic.containsKey("sent")) {
long sent = traffic.getLongOrDefault("sent", 0);
long failed = traffic.getLongOrDefault("failed", 0);
- return sent + (sent == 1 ? " message" : " messages") + " sent to "
+ endpoint
- + (failed > 0 ? " (" + failed + " failed)" : "") + " in "
- + time(traffic.getLongOrDefault("elapsed", 0));
+ StringBuilder sb = new StringBuilder();
+ sb.append(sent).append(sent == 1 ? " message" : "
messages").append(" sent to ").append(endpoint)
+ .append(failed > 0 ? " (" + failed + " failed)" :
"").append(" in ")
+ .append(time(traffic.getLongOrDefault("elapsed", 0)));
+ // the status codes the clients of an HTTP feed got (CAMEL-26793)
+ JsonObject codes = (JsonObject) traffic.get("statusCodes");
+ if (codes != null && !codes.isEmpty()) {
+ StringJoiner sj = new StringJoiner(", ");
+ new TreeMap<>(codes).forEach((code, n) -> sj.add(code + " x" +
n));
+ sb.append(", answered ").append(sj);
+ }
+ if (traffic.containsKey("planned")) {
+ sb.append(" (of
").append(traffic.getLong("planned")).append(": the experiment ended first)");
+ }
+ return sb.toString();
}
// a single message: the answer of the send
return "1 message sent to " + endpoint +
("success".equals(traffic.getString("status")) ? "" : " (failed)");
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 5a06f0074ca7..7570f58137a6 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
@@ -88,9 +88,11 @@ public final class ChaosExplorePlaybook {
- feed: a route that does not feed itself gets no messages
unless you give feed. When the feed \
of the suggestion is kind send, always give its endpoint, with
body (a message as the route \
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. An experiment refuses a feed that cannot
reach its route, with the reason and \
+ only when the route feeds itself (kind self). A route of kind
http (platform-http, rest, or an HTTP server of its own) is fed \
+ with HTTP requests to the server of the integration: give the
id of the route as feed, or the path \
+ to call when its path has a parameter (such as POST
/orders/42); the traffic counts the status codes \
+ the clients got. Routes fed from files cannot be fed by an
experiment yet. 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 \
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index 045e389c4808..74c234addefb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -973,7 +973,8 @@ public final class ToolRegistry {
.param("hypothesis", "string", "What the experiment tests, in
a few words", false)
.param("feed", "string",
"The endpoint, or the id of the route, to send the
messages to (as the feed of chaos_suggest);"
- + " leave it out when the route feeds
itself (a timer)",
+ + " a route that serves HTTP gets
HTTP requests, or give the path to call"
+ + " (POST /orders/42); leave it out
when the route feeds itself (a timer)",
false)
.param("body", "string", "The body of the messages
(file:<path>, or file:<folder> for a message per file)",
false)
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 03f7bceb964d..8eef718c9a2d 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,8 +16,16 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.StringJoiner;
import java.util.function.Consumer;
import java.util.regex.Matcher;
@@ -67,6 +75,27 @@ public final class ChaosExperimentRun {
/** The status of the integration, as it wrote it last, or null. */
JsonObject status();
+
+ /**
+ * Sends one HTTP request of a feed to the integration's own server
(CAMEL-26793), and returns the status code
+ * of its answer.
+ */
+ default int http(String method, String url, Map<String, String>
headers, String body)
+ throws IOException, InterruptedException {
+ HttpRequest.Builder builder =
HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(30));
+ headers.forEach(builder::header);
+ builder.method(method, body == null || body.isEmpty()
+ ? HttpRequest.BodyPublishers.noBody()
+ : HttpRequest.BodyPublishers.ofString(body,
StandardCharsets.UTF_8));
+ return HttpFeedClient.INSTANCE.send(builder.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+ }
+
+ private static final class HttpFeedClient {
+ static final HttpClient INSTANCE = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
}
/**
@@ -142,12 +171,16 @@ public final class ChaosExperimentRun {
// 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;
+ ChaosHttpFeed.Target http = null;
if (feed != null) {
// a feed that reaches no route makes an experiment without
messages, whose verdict says nothing
String why = checkFeed(status, feed.endpoint());
if (why != null) {
throw new IllegalArgumentException(why);
}
+ // a route that serves HTTP is fed with requests to the server of
the integration
+ List<Object> routes = status != null ?
status.getCollection("routes") : null;
+ http = ChaosHttpFeed.target(status, routes != null ? route(routes,
feed.endpoint()) : null, feed.endpoint());
}
// an expectation such as failed<=0 is decided when its within= is up:
a shorter wait leaves it undecided
long within = longestWithin(rules);
@@ -175,8 +208,14 @@ public final class ChaosExperimentRun {
}
JsonObject traffic = null;
long waited = 0;
+ ChaosHttpFeed.Sender sender = null;
+ Thread sending = null;
try {
- if (feed != null) {
+ if (http != null) {
+ // sent while the experiment waits for its verdict, and
stopped when it ends
+ sender = new ChaosHttpFeed.Sender(integration, http, feed,
clock);
+ sending =
Thread.ofVirtual().name("chaos-http-feed").start(sender);
+ } else if (feed != null) {
traffic = traffic(integration, feed);
}
long start = clock.now();
@@ -193,6 +232,15 @@ public final class ChaosExperimentRun {
}
waited = clock.now() - start;
} finally {
+ if (sender != null) {
+ sender.stop();
+ // the interrupt cancels a request in flight
(HttpClient.send), and the sender sends no more after
+ // stop: a request that does not end in time lingers at most
until its timeout, also in a long-running
+ // JVM such as the MCP server
+ sending.interrupt();
+ sending.join(5000);
+ traffic = sender.toJson();
+ }
// ended whatever happened, so its rules do not stay
JsonObject result = end(integration, number, note, clock);
if (result != null) {
@@ -281,7 +329,7 @@ public final class ChaosExperimentRun {
*/
public static String checkFeed(JsonObject status, String feed) {
List<Object> routes = status != null ? status.getCollection("routes")
: null;
- if (routes == null || routes.isEmpty() || feed.endsWith("*")) {
+ if (routes == null || routes.isEmpty() || feed.endsWith("*") ||
ChaosHttpFeed.isPath(feed)) {
return null;
}
JsonObject route = route(routes, feed);
@@ -307,8 +355,9 @@ public final class ChaosExperimentRun {
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())) {
+ // a file is written to the folder the route polls, and an HTTP route
is fed by the integration's own server
+ if (model == null || !model.isRemote() ||
"file".equals(model.getScheme()) || ChaosHttpFeed.servedByCamel(from)
+ || ChaosHttpFeed.ownAddress(from) != null) {
return null;
}
String id = route.getString("routeId");
@@ -348,9 +397,18 @@ public final class ChaosExperimentRun {
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 (ChaosHttpFeed.servedByCamel(from)) {
+ // fed with HTTP requests to the server of the integration
(CAMEL-26793)
+ return null;
+ }
+ if (label.contains("http")) {
+ if (ChaosHttpFeed.ownAddress(from) != null) {
+ // an HTTP server of its own on this machine: fed with
requests to its address
+ return null;
+ }
+ return "Route " + route + " starts from the HTTP endpoint " + from
+ ", which an experiment cannot feed:"
+ + " it has no address to call, or one on another host. 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"
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosHttpFeed.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosHttpFeed.java
new file mode 100644
index 000000000000..a2dd12900be5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosHttpFeed.java
@@ -0,0 +1,355 @@
+/*
+ * 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.dsl.jbang.core.common;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.cli.connector.RepeatedSend;
+import org.apache.camel.dsl.jbang.core.commands.ai.HttpEndpoints;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The feed of a chaos experiment for a route that serves HTTP (CAMEL-26793):
such a route starts from platform-http or
+ * the REST DSL, which has no producer, so it is fed with HTTP requests to the
integration's own server (localhost and
+ * the port of its HTTP server, as the status has them). The experiment sends
them while it waits for the verdict, and
+ * its traffic counts the status codes the clients got.
+ */
+public final class ChaosHttpFeed {
+
+ /** Where the requests go: the method and the URL on the integration's own
server. */
+ public record Target(String method, String url) {
+
+ @Override
+ public String toString() {
+ return method + " " + url;
+ }
+ }
+
+ // a path to call, with an optional method: /orders/42, PUT /orders/42
+ private static final Pattern PATH
+ =
Pattern.compile("(?:(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\s+)?(/\\S*)",
Pattern.CASE_INSENSITIVE);
+
+ private ChaosHttpFeed() {
+ }
+
+ /** Whether the feed is a path to call, such as /orders/42 or PUT
/orders/42, rather than a route or endpoint. */
+ public static boolean isPath(String feed) {
+ return feed != null && PATH.matcher(feed.strip()).matches();
+ }
+
+ /** Whether the route starts from an input that is fed over the HTTP
server of Camel: platform-http or rest. */
+ static boolean servedByCamel(String from) {
+ return from != null && (from.startsWith("platform-http:") ||
from.startsWith("rest:"));
+ }
+
+ // an HTTP server with its own address in the URI:
netty-http:http://0.0.0.0:9000/hooks
+ private static final Pattern OWN_ADDRESS = Pattern.compile(
+
"(?:netty-http|undertow|jetty|vertx-http):(?://)?(https?)://([^/:?]+)(:\\d+)?([^?]*)(?:\\?.*)?");
+ private static final Set<String> LOCAL_HOSTS = Set.of("localhost",
"127.0.0.1", "0.0.0.0", "::1", "[::1]");
+
+ /**
+ * The URL to call for a route that starts from an HTTP server with its
own address on this machine (netty-http,
+ * undertow, jetty), or null: the host it listens on is called as
localhost.
+ */
+ static String ownAddress(String from) {
+ Matcher m = from != null ? OWN_ADDRESS.matcher(from) : null;
+ if (m == null || !m.matches() || !LOCAL_HOSTS.contains(m.group(2))) {
+ return null;
+ }
+ String path = m.group(4).isEmpty() ? "/" : m.group(4);
+ return m.group(1) + "://localhost" + (m.group(3) != null ? m.group(3)
: "") + path;
+ }
+
+ /**
+ * Where the requests of the feed go: for a path to call, or for a route
that starts from platform-http or rest (by
+ * its id or endpoint); null when the feed is for another route.
+ *
+ * @throws IllegalArgumentException when the feed is for HTTP but cannot
be sent, with the reason
+ */
+ public static Target target(JsonObject status, JsonObject route, String
feed) {
+ Matcher m = PATH.matcher(feed.strip());
+ String method;
+ String path;
+ String routeId = null;
+ if (m.matches()) {
+ method = m.group(1);
+ path = m.group(2);
+ } else if (route != null && ownAddress(route.getString("from"))
instanceof String address) {
+ // its own server on this machine: no port to look up
+ String m0 = method(route.getString("from"));
+ return new Target(m0 != null ? m0 : "POST", address);
+ } else if (route != null && servedByCamel(route.getString("from"))) {
+ routeId = route.getString("routeId");
+ String from = route.getString("from");
+ method = method(from);
+ path = path(from);
+ } else {
+ return null;
+ }
+ HttpEndpoints.Served served = HttpEndpoints.fromStatus(status);
+ int port = served != null ? served.port() : 0;
+ if (port <= 0) {
+ throw new IllegalArgumentException(
+ "The feed " + feed + " is sent over HTTP, but the port of
the HTTP server of the integration is not"
+ + " known from its status");
+ }
+ // the path as served, with the context path of the server or the REST
DSL
+ HttpEndpoints.Endpoint served0 = served(served, routeId, method, path);
+ if (served0 != null) {
+ path = served0.path();
+ if (method == null && !"ANY".equals(served0.method())) {
+ method = served0.method();
+ }
+ }
+ if (path.contains("{")) {
+ throw new IllegalArgumentException(
+ "Route " + (routeId != null ? routeId : feed) + " serves "
+ path + ", with a parameter: give the"
+ + " feed as the path to call,
such as POST "
+ + path.replaceAll("\\{[^}]*}",
"1"));
+ }
+ String m0 = method != null ? method.toUpperCase(Locale.ROOT) : "POST";
+ return new Target(m0, "http://localhost:" + port + path);
+ }
+
+ private static HttpEndpoints.Endpoint served(
+ HttpEndpoints.Served served, String routeId, String method, String
path) {
+ if (served == null) {
+ return null;
+ }
+ HttpEndpoints.Endpoint answer = null;
+ for (HttpEndpoints.Endpoint e : served.endpoints()) {
+ if (routeId != null && routeId.equals(e.routeId())) {
+ return e;
+ }
+ boolean samePath = e.path().equals(path) ||
(e.path().endsWith(path) && !"/".equals(path));
+ if (samePath && (method == null ||
method.equalsIgnoreCase(e.method()) || "ANY".equals(e.method()))) {
+ if (answer == null || e.path().equals(path)) {
+ answer = e;
+ }
+ }
+ }
+ return answer;
+ }
+
+ /** The method of a platform-http or rest input: the one it restricts to,
else null. */
+ static String method(String from) {
+ if (from.startsWith("rest:")) {
+ String[] parts = from.split(":", 3);
+ return parts.length == 3 ? parts[1].toUpperCase(Locale.ROOT) :
null;
+ }
+ int query = from.indexOf('?');
+ if (query > 0) {
+ for (String option : from.substring(query + 1).split("&")) {
+ if (option.startsWith("httpMethodRestrict=")) {
+ return
option.substring(19).split("[,%]")[0].trim().toUpperCase(Locale.ROOT);
+ }
+ }
+ }
+ return null;
+ }
+
+ /** The path of a platform-http or rest input, such as /payment. */
+ static String path(String from) {
+ String path;
+ if (from.startsWith("rest:")) {
+ String[] parts = from.split(":", 3);
+ path = parts.length == 3 ? parts[2] : "";
+ } else {
+ path = from.substring(from.indexOf(':') + 1);
+ }
+ int query = path.indexOf('?');
+ if (query >= 0) {
+ path = path.substring(0, query);
+ }
+ while (path.startsWith("//")) {
+ path = path.substring(1);
+ }
+ return path.startsWith("/") ? path : "/" + path;
+ }
+
+ /** The bodies of the feed: the text, the content of a file, or of each
file of a folder (in name order). */
+ static List<String> bodies(String body) throws IOException {
+ if (body == null) {
+ return List.of("");
+ }
+ if (!body.startsWith("file:")) {
+ return List.of(body);
+ }
+ File file = new File(body.substring(5));
+ if (file.isDirectory()) {
+ File[] files = file.listFiles(File::isFile);
+ if (files == null || files.length == 0) {
+ throw new IOException("No files in the folder " + file);
+ }
+ Arrays.sort(files);
+ List<String> answer = new ArrayList<>();
+ for (File f : files) {
+ answer.add(Files.readString(f.toPath(),
StandardCharsets.UTF_8));
+ }
+ return answer;
+ }
+ return List.of(Files.readString(file.toPath(),
StandardCharsets.UTF_8));
+ }
+
+ /** The headers of the feed, as key=value (or key: value) lines, as HTTP
headers. */
+ static Map<String, String> headers(String headers, String body) {
+ Map<String, String> answer = new LinkedHashMap<>();
+ if (headers != null) {
+ for (String line : headers.split("\n")) {
+ int sep = indexOfSeparator(line);
+ if (sep > 0) {
+ answer.put(line.substring(0, sep).strip(),
line.substring(sep + 1).strip());
+ }
+ }
+ }
+ boolean typed = answer.keySet().stream().anyMatch(k ->
k.equalsIgnoreCase("Content-Type"));
+ String b = body != null ? body.stripLeading() : "";
+ if (!typed && (b.startsWith("{") || b.startsWith("["))) {
+ answer.put("Content-Type", "application/json");
+ }
+ return answer;
+ }
+
+ private static int indexOfSeparator(String line) {
+ int eq = line.indexOf('=');
+ int colon = line.indexOf(':');
+ if (eq < 0) {
+ return colon;
+ }
+ return colon < 0 ? eq : Math.min(eq, colon);
+ }
+
+ /**
+ * Sends the requests of a feed, one after the other with the pause of its
interval, until all are sent or it is
+ * stopped (the experiment ended); counts the status codes the clients got.
+ */
+ static final class Sender implements Runnable {
+
+ private final ChaosExperimentRun.Integration integration;
+ private final Target target;
+ private final ChaosExperimentRun.Feed feed;
+ private final ChaosExperimentRun.Clock clock;
+ private final Map<Integer, Long> codes = new TreeMap<>();
+ private volatile boolean stopped;
+ private long sent;
+ private long failed;
+ private long planned;
+ private long elapsed;
+ private String error;
+
+ Sender(ChaosExperimentRun.Integration integration, Target target,
ChaosExperimentRun.Feed feed,
+ ChaosExperimentRun.Clock clock) {
+ this.integration = integration;
+ this.target = target;
+ this.feed = feed;
+ this.clock = clock;
+ }
+
+ void stop() {
+ stopped = true;
+ }
+
+ @Override
+ public void run() {
+ long start = clock.now();
+ try {
+ List<String> bodies = bodies(feed.body());
+ long[] interval = RepeatedSend.interval(feed.interval());
+ int count = feed.repeat() > 0 ? feed.repeat() : bodies.size();
+ synchronized (this) {
+ planned = count;
+ }
+ for (int i = 0; i < count && !stopped; i++) {
+ if (i > 0) {
+ long pause = interval[1] > interval[0]
+ ?
ThreadLocalRandom.current().nextLong(interval[0], interval[1] + 1) :
interval[0];
+ if (pause > 0) {
+ clock.sleep(pause);
+ }
+ if (stopped) {
+ break;
+ }
+ }
+ String body = bodies.get(i % bodies.size());
+ try {
+ int code = integration.http(target.method(),
target.url(), headers(feed.headers(), body),
+ body);
+ synchronized (this) {
+ sent++;
+ codes.merge(code, 1L, Long::sum);
+ }
+ } catch (IOException | RuntimeException e) {
+ synchronized (this) {
+ failed++;
+ error = e.getMessage() != null ? e.getMessage() :
e.getClass().getSimpleName();
+ }
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (IOException | RuntimeException e) {
+ synchronized (this) {
+ error = e.getMessage();
+ }
+ } finally {
+ synchronized (this) {
+ elapsed = clock.now() - start;
+ }
+ }
+ }
+
+ /** The traffic: the requests sent and answered with each status code,
failed (no answer), and why. */
+ synchronized JsonObject toJson() {
+ JsonObject answer = new JsonObject();
+ answer.put("endpoint", target.toString());
+ answer.put("sent", sent + failed);
+ answer.put("failed", failed);
+ answer.put("elapsed", elapsed);
+ JsonObject statusCodes = new JsonObject();
+ codes.forEach((code, n) -> statusCodes.put(Integer.toString(code),
n));
+ answer.put("statusCodes", statusCodes);
+ if (sent + failed < planned) {
+ // the experiment ended before all were sent
+ answer.put("planned", planned);
+ }
+ if (sent == 0 && error != null) {
+ answer.put("status", "failed");
+ answer.put("error", error);
+ } else {
+ answer.put("status", "success");
+ if (error != null) {
+ answer.put("lastError", error);
+ }
+ }
+ return answer;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosActionTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosActionTest.java
index 5ec3f1c84b8f..0d01abe00a49 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosActionTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosActionTest.java
@@ -593,6 +593,17 @@ class CamelChaosActionTest extends
ActionCommandTestSupport {
Map.of("number", 3, "verdict", "PASS", "elapsed", 1_000L,
"traffic", new JsonObject(Map.of("endpoint",
"refunds", "status", "failed", "error", "No endpoint"))));
assertThat(CamelChaosExperimentAction.lines(notSent)).contains("
traffic: not sent to refunds: No endpoint");
+
+ // an HTTP feed: the status codes the clients got (CAMEL-26793)
+ JsonObject http = new JsonObject(
+ Map.of("number", 3, "verdict", "PASS", "rules", new
JsonArray(List.of("r3")),
+ "traffic", new JsonObject(
+ Map.of("endpoint", "POST
http://localhost:8080/payment", "sent", 20L,
+ "failed", 0L, "elapsed", 4_200L,
"planned", 30L,
+ "statusCodes", new
JsonObject(Map.of("200", 17L, "503", 3L))))));
+ assertThat(CamelChaosExperimentAction.lines(http)).contains("
traffic: 20 messages sent to POST"
+ + "
http://localhost:8080/payment in 4s, answered 200 x17, 503 x3 (of 30: the"
+ + "
experiment ended first)");
}
/** The status of the integration orders, with the chaos dev console as
the CLI connector puts it there. */
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 8df8c2737a6a..f8462bfea3fc 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
@@ -18,9 +18,12 @@ package org.apache.camel.dsl.jbang.core.common;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.Deque;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.apache.camel.util.json.JsonArray;
@@ -46,6 +49,10 @@ class ChaosExperimentRunTest {
RuntimeException beginFails;
List<String> beginWarnings;
JsonObject ended = experiment("FAIL", false);
+ // the HTTP requests of a feed, and the reads of the status that wait
until they are all sent
+ final List<String> requests = Collections.synchronizedList(new
ArrayList<>());
+ CountDownLatch httpDone;
+ int statusReads;
@Override
public JsonObject chaos(String command, Consumer<JsonObject> options) {
@@ -80,9 +87,27 @@ class ChaosExperimentRunTest {
@Override
public JsonObject status() {
+ // the first read is the check of the feed, before the requests
+ if (httpDone != null && statusReads++ > 0) {
+ try {
+ httpDone.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
return statuses.size() > 1 ? statuses.poll() : statuses.peek();
}
+ @Override
+ public int http(String method, String url, Map<String, String>
headers, String body) {
+ requests.add(method + " " + url + " " + headers + " " + body);
+ int code = requests.size() % 4 == 0 ? 503 : 200;
+ if (httpDone != null) {
+ httpDone.countDown();
+ }
+ return code;
+ }
+
void shows(String verdict, boolean open) {
statuses.add(statusWith(experiment(verdict, open)));
}
@@ -237,6 +262,8 @@ class ChaosExperimentRunTest {
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", "hooks",
"from", "netty-http:http://0.0.0.0:9000/hooks")),
+ new JsonObject(Map.of("routeId", "remote",
"from", "undertow:http://shop.example.com:9000/x")),
new JsonObject(Map.of("routeId", "inbox",
"from", "file://inbox?delete=true")),
new JsonObject(Map.of("routeId", "billing",
"from", "seda://billing"))))));
integration.statuses.add(status);
@@ -245,12 +272,16 @@ class ChaosExperimentRunTest {
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 route served by the HTTP server of Camel is fed with requests to
it (CAMEL-26793), another HTTP server not
+ assertThat(ChaosExperimentRun.checkFeed(status, "payment")).isNull();
+ assertThat(ChaosExperimentRun.checkFeed(status,
"platform-http:/payment")).isNull();
+ assertThat(ChaosExperimentRun.checkFeed(status, "api")).isNull();
+ assertThat(ChaosExperimentRun.checkFeed(status, "POST
/refunds")).isNull();
+ // an HTTP server of its own on this machine is fed at its address;
one on another host is not
+ assertThat(ChaosExperimentRun.checkFeed(status, "hooks")).isNull();
+ assertThat(ChaosExperimentRun.checkFeed(status, "remote"))
+ .startsWith("Route remote starts from the HTTP endpoint
undertow:http://shop.example.com:9000/x, which an"
+ + " experiment cannot feed");
// 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
@@ -264,6 +295,82 @@ class ChaosExperimentRunTest {
assertThat(integration.actions).isEmpty();
}
+ private static JsonObject served(JsonObject status) {
+ JsonObject served = new JsonObject(status);
+ served.put("routes", new JsonArray(
+ List.of(
+ new JsonObject(
+ Map.of("routeId", "payment", "from",
"platform-http:///payment?httpMethodRestrict=POST")),
+ new JsonObject(Map.of("routeId", "orders", "from",
"rest:get:/orders/{id}")))));
+ served.put("platform-http", new JsonObject(
+ Map.of(
+ "server", "http://0.0.0.0:8080",
+ "endpoints", new JsonArray(
+ List.of(
+ new JsonObject(
+ Map.of("url",
"http://0.0.0.0:8080/api/payment", "path", "/api/payment",
+ "verbs", "POST")))))));
+ served.put("rests", new JsonObject(
+ Map.of("rests", new JsonArray(
+ List.of(
+ new JsonObject(
+ Map.of("url",
"http://0.0.0.0:8080/api/orders/{id}", "method", "get", "routeId",
+ "orders")))))));
+ return served;
+ }
+
+ @Test
+ void anHttpRouteIsFedWithRequestsToTheServerOfTheIntegration() throws
Exception {
+ // read by the check of the feed: the routes and the HTTP server
+ integration.statuses.add(served(statusWith(experiment("PENDING",
true))));
+ integration.shows("PENDING", true);
+ integration.shows("FAIL", true);
+ integration.httpDone = new CountDownLatch(4);
+
+ JsonObject answer = ChaosExperimentRun.run(integration, "a client gets
a 503, not a 500", RULE,
+ new ChaosExperimentRun.Feed("payment", "{\"amount\": 5}",
"X-Shop=demo", 4, null), 60_000, null, clock);
+
+ // the path as served (with its context path), the method of the
route, the headers and a JSON content type
+ assertThat(integration.requests).hasSize(4).allMatch(r -> r.equals(
+ "POST http://localhost:8080/api/payment {X-Shop=demo,
Content-Type=application/json} {\"amount\": 5}"));
+ assertThat(integration.sent).isNull();
+ JsonObject traffic = (JsonObject) answer.get("traffic");
+ assertThat(traffic.getString("endpoint")).isEqualTo("POST
http://localhost:8080/api/payment");
+ assertThat(traffic.getLong("sent")).isEqualTo(4);
+ assertThat(((JsonObject)
traffic.get("statusCodes")).getLong("200")).isEqualTo(3);
+ assertThat(((JsonObject)
traffic.get("statusCodes")).getLong("503")).isEqualTo(1);
+ assertThat(answer.getString("verdict")).isEqualTo("FAIL");
+ }
+
+ @Test
+ void whereTheRequestsOfAnHttpFeedGo() {
+ JsonObject status = served(new JsonObject());
+ List<Object> routes = status.getCollection("routes");
+
+ // a path to call, with or without a method (CAMEL-26793)
+ assertThat(ChaosHttpFeed.target(status, null, "PUT /api/orders/42"))
+ .hasToString("PUT http://localhost:8080/api/orders/42");
+ assertThat(ChaosHttpFeed.target(status, null,
"/api/payment")).hasToString("POST http://localhost:8080/api/payment");
+ // a route with a parameter in its path: the feed gives the path to
call
+ assertThatThrownBy(() -> ChaosHttpFeed.target(status, (JsonObject)
routes.get(1), "orders"))
+ .hasMessage("Route orders serves /api/orders/{id}, with a
parameter: give the feed as the path to call,"
+ + " such as POST /api/orders/1");
+ // an HTTP server of its own: its address, called as localhost, with
its method
+ assertThat(ChaosHttpFeed.target(status,
+ new JsonObject(
+ Map.of("routeId", "hooks",
+ "from",
"netty-http://http://0.0.0.0:9000/hooks?httpMethodRestrict=PUT")),
+ "hooks")).hasToString("PUT http://localhost:9000/hooks");
+
assertThat(ChaosHttpFeed.ownAddress("jetty:http://localhost:8181")).isEqualTo("http://localhost:8181/");
+
assertThat(ChaosHttpFeed.ownAddress("undertow:https://10.0.0.5:8443/api")).isNull();
+ // another route: not for HTTP
+ assertThat(ChaosHttpFeed.target(status, new
JsonObject(Map.of("routeId", "x", "from", "seda://x")), "x")).isNull();
+ // a server whose port the status does not have
+ assertThatThrownBy(() -> ChaosHttpFeed.target(new
JsonObject(Map.of("routes", status.get("routes"))),
+ (JsonObject) routes.get(0), "payment"))
+ .hasMessageContaining("the port of the HTTP server of the
integration is not known");
+ }
+
@Test
void aFeedToARealSystemIsWarned() {
JsonObject report = new JsonObject(
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
index 4d03a9ef8a3f..9398ab867eb6 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt
@@ -775,7 +775,9 @@ rules, send its traffic, wait for the verdict, and end it
camel.chaos.rules; can be given more than once,
and one of them needs an expectation (expect=)
--feed=<feed> The endpoint, or the id of the route, to send the
- messages of the experiment to
+ messages of the experiment to; a route that
+ serves HTTP gets HTTP requests, or give the path
+ to call, such as 'PUT /orders/42'
--body=<body> The body of the messages: text, file:<path>, or
file:<folder> for a message per file
--header=<headers> A header of the messages as key=value; can be given
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-parse.txt
b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-parse.txt
index e44083f6722e..4a87d002d15e 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-parse.txt
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-parse.txt
@@ -590,6 +590,57 @@
timeoutOption.timeout=7
> cmd chaos-experiment --wait v
wait="v"
+> cmd chaos-explore
+ apiKey=null
+ apiType=null
+ budget="10m"
+ cmd:helpRequested=false
+ focus=null
+ helpRequested=false
+ maxIterations=100
+ model=null
+ name=null
+ save=true
+ showTools=false
+ timeout=300
+ tools=auto
+ url=null
+ verbose=false
+ yes=false
+> cmd chaos-explore --api-key v
+ apiKey="v"
+> cmd chaos-explore --api-type ollama
+ apiType=ollama
+> cmd chaos-explore --budget v
+ budget="v"
+> cmd chaos-explore --focus v
+ focus="v"
+> cmd chaos-explore --max-iterations 7
+ maxIterations=7
+> cmd chaos-explore --model v
+ model="v"
+> cmd chaos-explore --save
+ (defaults)
+> cmd chaos-explore --save=false
+ save=false
+> cmd chaos-explore --show-tools
+ showTools=true
+> cmd chaos-explore --show-tools=false
+ (defaults)
+> cmd chaos-explore --timeout 7
+ timeout=7
+> cmd chaos-explore --tools auto
+ (defaults)
+> cmd chaos-explore --url v
+ url="v"
+> cmd chaos-explore --verbose
+ verbose=true
+> cmd chaos-explore --verbose=false
+ (defaults)
+> cmd chaos-explore --yes
+ yes=true
+> cmd chaos-explore --yes=false
+ (defaults)
> cmd chaos-explore-finish
cmd:helpRequested=false
helpRequested=false
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
index 813e34815deb..a4a02ee25e52 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
@@ -1304,7 +1304,8 @@ public class RuntimeTools {
+ " such as 'route=refunds error
rate=every:5 expect=refunds:failed<=0 within=30s'") String rule,
@ToolArg(description = "What the experiment tests, in a few
words", required = false) String hypothesis,
@ToolArg(description = "The endpoint, or the id of the route, to
send the messages to (as the feed of"
- + " camel_runtime_chaos_suggest); leave it
out when the route feeds itself",
+ + " camel_runtime_chaos_suggest); a route
that serves HTTP gets HTTP requests, or"
+ + " give the path to call (POST
/orders/42); leave it out when the route feeds itself",
required = false) String feed,
@ToolArg(description = "The body of the messages (file:<path>, or
file:<folder> for a message per file)",
required = false) String body,