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 4a2b40ad6a90 CAMEL-26786: camel-jbang - chaos exploration tools for
camel ask and the MCP server: explore start, one experiment per call, explore
finish (#29499)
4a2b40ad6a90 is described below
commit 4a2b40ad6a908f2d9a1e52129ed74e6b3e78f26a
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 23:31:42 2026 +0100
CAMEL-26786: camel-jbang - chaos exploration tools for camel ask and the
MCP server: explore start, one experiment per call, explore finish (#29499)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 24 +++-
.../camel/dsl/jbang/core/commands/AskTools.java | 13 +-
.../dsl/jbang/core/commands/ai/ToolConfirms.java | 19 ++-
.../dsl/jbang/core/commands/ai/ToolGroups.java | 11 +-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 116 +++++++++++++++++
.../dsl/jbang/core/commands/AskToolsTest.java | 23 +++-
.../core/commands/ai/ChaosExploreToolsTest.java | 141 +++++++++++++++++++++
.../dsl/jbang/core/commands/ai/ToolGroupsTest.java | 10 +-
.../jbang/core/commands/ai/ToolRegistryTest.java | 17 ++-
.../dsl/jbang/core/commands/mcp/RuntimeTools.java | 84 ++++++++++++
.../mcp/RuntimeToolsChaosSecurityTest.java | 23 +++-
11 files changed, 461 insertions(+), 20 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index bf85f8735035..5b5d95819925 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -1124,14 +1124,34 @@ that changes rules is refused. The list and the report
read the chaos console fr
| Loads a scenario (`scenario`, the text of a `.chaos` file, and optionally
its name `scenarioName`), as
`camel cmd chaos-load` does: the rules there are now are removed and the
report starts again, then the rules of the
scenario are added, all or none. Returns the ids, each rule in plain words,
and `warnings`.
+
+| `camel_runtime_chaos_explore_start`
+| `false` | `false`
+| Starts an exploration, as `camel cmd chaos-explore-start` does: a time to
try rules in (`budget`, 10 minutes by
+ default and at most 2 hours, and an optional `focus`). While it runs a rule
ends at the deadline at the latest, and at
+ the deadline the rules are removed, whatever the client does.
+
+| `camel_runtime_chaos_experiment`
+| `false` | `true`
+| Runs one experiment in the exploration in one call, as `camel cmd
chaos-experiment` does: adds the rules (`rule`,
+ one of them with `expect=`), sends the messages of `feed` (with `body`,
`headers`, `repeat` and `interval`), waits until
+ the expectations are decided (at most `wait`, 2 minutes by default), and
ends it. Returns the `verdict` (`FAIL`,
+ `PASS` or `INCONCLUSIVE`), the expectations, the faults per rule, what the
routes did, the `traffic` and `timeLeft`.
+
+| `camel_runtime_chaos_explore_finish`
+| `false` | `false`
+| Finishes the exploration before its deadline, as `camel cmd
chaos-explore-finish` does, and returns its journal: the
+ rules tried and the experiments with their verdicts.
|===
-Adding and changing a rule, and loading a scenario, are marked destructive: a
rule makes messages fail, wait or get lost, and its
+Adding and changing a rule, loading a scenario, and an experiment are marked
destructive: a rule makes messages fail, wait or get lost, and its
`exception=` can name any exception class. So a client that honours
`destructiveHint` asks before it runs them, and
the `read-write` access level of the security layer
(`camel.mcp.security.access-level`) does not offer them, as it does
not offer `camel_runtime_route_control` or `camel_runtime_sql`; `read-only`
offers the list, the report, what to try and the check. Removing
and clearing only take faults away, and the report keeps the removed rules, so
they are not destructive. `camel ask`
-has the same tools (`chaos_add` and the others), and asks the user before it
adds or changes a rule or loads a scenario (showing the rules of the scenario).
The
+has the same tools (`chaos_add` and the others), and asks the user before it
adds or changes a rule or loads a scenario (showing the rules of the scenario).
It asks
+once for an exploration (`chaos_explore_start`), and not again for the
experiments in it, as camel-chaos removes their
+rules at its deadline; the messages of an experiment are asked as
`send_message` asks them. The
development MCP tools that `camel run --mcp` serves over HTTP have only the
list and the report: a rule is never added,
changed, removed, checked, suggested or loaded over the HTTP server of the
application.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 36c12a883903..f212812aa219 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
@@ -77,7 +77,10 @@ public class AskTools {
Map.entry(ToolGroups.CHAOS_REMOVE_TOOL, "chaos_remove"),
Map.entry(ToolGroups.CHAOS_CLEAR_TOOL, "chaos_clear"),
Map.entry(ToolGroups.CHAOS_SUGGEST_TOOL, "chaos_suggest"),
- Map.entry(ToolGroups.CHAOS_LOAD_TOOL, "chaos_load"));
+ Map.entry(ToolGroups.CHAOS_LOAD_TOOL, "chaos_load"),
+ Map.entry(ToolGroups.CHAOS_EXPLORE_START_TOOL,
"chaos_explore_start"),
+ Map.entry(ToolGroups.CHAOS_EXPERIMENT_TOOL, "chaos_experiment"),
+ Map.entry(ToolGroups.CHAOS_EXPLORE_FINISH_TOOL,
"chaos_explore_finish"));
private static final String CLI_LIST_COMMANDS = "cli_list_commands";
private static final String CLI_COMMAND_HELP = "cli_command_help";
@@ -358,6 +361,14 @@ public class AskTools {
args.get("name") != null ? args.get("name") :
processName(), args.get("id"), args.get("rule"));
case "chaos_load" -> ToolConfirms.chaosLoad(args.get("name") !=
null ? args.get("name") : processName(),
args.get("scenarioName"), args.get("scenario"));
+ // the consent to the experiments of the exploration, which
camel-chaos ends at its deadline
+ case "chaos_explore_start" -> ToolConfirms.chaosExplore(
+ args.get("name") != null ? args.get("name") :
processName(), args.get("budget"), args.get("focus"));
+ // its rules are covered by the exploration; its messages are
confirmed as send_message confirms them
+ case "chaos_experiment" -> args.get("feed") == null ||
args.get("feed").isBlank()
+ ? null
+ : ToolConfirms.sendMessage(args.get("feed"),
endpointUris(),
+ ToolConfirms.fileOutsideProject(args.get("body"),
runtimeDirectory(), ctx.defaultDirectory()));
default -> null;
};
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
index 11941657f920..db5d2b148f1b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolConfirms.java
@@ -45,7 +45,8 @@ import org.apache.camel.util.URISupport;
* <li>a SQL statement that is not a single SELECT (or SHOW, DESCRIBE), and a
row update;</li>
* <li>an expression in a language that runs code, such as Groovy;</li>
* <li>a chaos rule added or changed (camel-chaos): it makes messages of the
integration fail, wait or get lost, and can
- * name any exception class. Removing a rule only takes faults away.</li>
+ * name any exception class. Removing a rule only takes faults away. A chaos
exploration is confirmed once, when it
+ * starts: the experiments in it add rules without asking again, and
camel-chaos removes them at its deadline.</li>
* </ul>
* A file write is confirmed with its diff ({@link #write}). {@code camel
ask}, which shows no screen of the integration
* while it works, also asks before a call that stops or starts an
integration, browses an endpoint the integration does
@@ -331,6 +332,22 @@ public final class ToolConfirms {
+ " they are active, the integration's messages fail, wait or get lost");
}
+ /**
+ * The call of starting a chaos exploration (chaos_explore_start): the
consent to the experiments the AI then runs
+ * in it (chaos_experiment), each adding chaos rules without asking again.
camel-chaos ends them at the deadline of
+ * the exploration, whatever the AI does.
+ */
+ public static Call chaosExplore(String integration, String budget, String
focus) {
+ String where = integration != null && !integration.isBlank() ?
integration : "the selected integration";
+ String time = budget != null && !budget.isBlank() ? budget.strip() :
"10m";
+ return new Call(
+ "explore chaos in " + where + " for " + time,
+ focus != null && !focus.isBlank() ? "focus: " + focus.strip()
: "all routes",
+ "For that time the AI adds chaos rules without asking again,
and the integration's messages fail, wait"
+
+ " or get lost; at the end the"
+
+ " rules are removed");
+ }
+
/**
* Splits a text with several rules separated by {@code ;}, as in
camel.chaos.rules; a {@code ;} inside quotes does
* not split.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
index 76824c582e0f..29aeccd83d23 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
@@ -47,6 +47,9 @@ public final class ToolGroups {
public static final String CHAOS_CLEAR_TOOL = "camel_runtime_chaos_clear";
public static final String CHAOS_SUGGEST_TOOL =
"camel_runtime_chaos_suggest";
public static final String CHAOS_LOAD_TOOL = "camel_runtime_chaos_load";
+ public static final String CHAOS_EXPLORE_START_TOOL =
"camel_runtime_chaos_explore_start";
+ public static final String CHAOS_EXPERIMENT_TOOL =
"camel_runtime_chaos_experiment";
+ public static final String CHAOS_EXPLORE_FINISH_TOOL =
"camel_runtime_chaos_explore_finish";
/**
* A loaded group.
@@ -143,7 +146,8 @@ public final class ToolGroups {
f.chaosOn()
? List.of(CHAOS_LIST_TOOL, CHAOS_REPORT_TOOL,
CHAOS_SUGGEST_TOOL, CHAOS_CHECK_TOOL,
CHAOS_ADD_TOOL, CHAOS_CHANGE_TOOL,
CHAOS_REMOVE_TOOL, CHAOS_CLEAR_TOOL,
- CHAOS_LOAD_TOOL)
+ CHAOS_LOAD_TOOL,
CHAOS_EXPLORE_START_TOOL, CHAOS_EXPERIMENT_TOOL,
+ CHAOS_EXPLORE_FINISH_TOOL)
: List.of(CHAOS_LIST_TOOL, CHAOS_REPORT_TOOL),
describeChaos(f)));
}
@@ -242,7 +246,10 @@ public final class ToolGroups {
+ CHAOS_REPORT_TOOL + " show the rules and what they did; " +
CHAOS_ADD_TOOL
+ " takes a rule such as endpoint=kafka:orders* error rate=0.1
until=5m, and " + CHAOS_CHECK_TOOL
+ " says what it would hit without adding it; " +
CHAOS_SUGGEST_TOOL
- + " suggests rules from what the routes do, and " +
CHAOS_LOAD_TOOL + " loads a scenario.";
+ + " suggests rules from what the routes do, and " +
CHAOS_LOAD_TOOL + " loads a scenario; "
+ + CHAOS_EXPLORE_START_TOOL + " starts an exploration with a
time budget, in which " + CHAOS_EXPERIMENT_TOOL
+ + " runs one experiment per call (rules, traffic and the wait
for the verdict), until "
+ + CHAOS_EXPLORE_FINISH_TOOL + ".";
}
/** {@code Circuit breakers in routes a, b}, or without the routes when
they are not known. */
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 16c490f78347..0c9f44d0454f 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
@@ -24,11 +24,13 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.cli.connector.RepeatedSend;
import
org.apache.camel.dsl.jbang.core.commands.action.CamelRouteStructureAction;
+import org.apache.camel.dsl.jbang.core.common.ChaosExperimentRun;
import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.ProcessFinder;
@@ -928,6 +930,120 @@ public final class ToolRegistry {
}
});
}));
+ // an exploration (CAMEL-26773, CAMEL-26786): camel-chaos keeps its
journal and ends its rules at the deadline,
+ // so starting one is the consent to the experiments in it, which need
no confirm of their own
+ register(tool("chaos_explore_start",
+ "Start a chaos exploration in the integration (as camel cmd
chaos-explore-start): a time to try chaos "
+ + "rules in, that camel-chaos
enforces. While it runs, a rule ends at the "
+ + "deadline at the latest, and at
the deadline the rules are removed, "
+ + "whatever happens. Its journal
(the baseline, the rules tried and the "
+ + "experiments) is in
chaos_report. Run experiments in it with "
+ + "chaos_experiment, and finish
it with chaos_explore_finish. One runs at a "
+ + "time.")
+ .param("budget", "string", "How long it runs, between 10s and
2h (default 10m)", false)
+ .param("focus", "string", "What it is about, such as a route
(optional)", false)
+ .param("name", "string", CHAOS_NAME, false)
+ .readOnly(false).destructive(false)
+ .executor((ctx, args) -> {
+ selectChaosProcess(ctx, args);
+ return ctx.executeAction("chaos", root -> {
+ root.put("command", "explore-start");
+ putIfSet(root, "budget", args.get("budget"));
+ putIfSet(root, "focus", args.get("focus"));
+ });
+ }));
+
+ register(tool("chaos_experiment",
+ "Run one chaos experiment in the running exploration (as camel
cmd chaos-experiment), in one call: it "
+ + "adds the rules (one of them with
an expectation, expect=, that decides the "
+ + "hypothesis), sends the messages
of feed when given (as send_message, with "
+ + "body, headers, repeat and
interval), waits until the expectations are "
+ + "decided or wait is up, and ends
the experiment, which removes its rules. "
+ + "Returns the verdict (FAIL: an
expectation is unmet; PASS: all are met; "
+ + "INCONCLUSIVE: the wait was up
before one was decided, so wait longer than "
+ + "its within=), the expectations
with their actual values, the faults per "
+ + "rule, what the routes did
meanwhile, the traffic sent, and the time left "
+ + "of the exploration (timeLeft,
millis). chaos_suggest has rules to try and "
+ + "the feed of each route.")
+ .param("rule", "string",
+ "The rules in their one-line text form, separated by
;, one of them with expect=, such as"
+ + " 'route=refunds error rate=every:5
expect=refunds:failed<=0 within=30s'",
+ true)
+ .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)",
+ false)
+ .param("body", "string", "The body of the messages
(file:<path>, or file:<folder> for a message per file)",
+ false)
+ .param("headers", "string", "The headers of the messages as
key=value pairs separated by newlines", false)
+ .param("repeat", "string", "How many messages to send in all
(at most 10000)", false)
+ .param("interval", "string",
+ "The pause between two messages in millis, or a range
such as 100..500 (at most 60000)", false)
+ .param("wait", "string",
+ "The longest wait for the verdict after the messages
were sent, such as 90s (default 2m, at most"
+ + " 10m)",
+ false)
+ .param("note", "string", "What to note with the experiment in
the journal", false)
+ .param("name", "string", CHAOS_NAME, false)
+ // its rules make messages fail, wait or get lost, within the
exploration the user started
+ .readOnly(false).destructive(true)
+ .executor((ctx, args) -> {
+ String rule = AuthoringTools.required(args, "rule");
+ String feed = args.get("feed");
+ long wait;
+ try {
+ RepeatedSend.interval(args.get("interval"));
+ wait = ChaosExperimentRun.waitMillis(args.get("wait"));
+ } catch (IllegalArgumentException e) {
+ throw new ToolExecutionException(e.getMessage());
+ }
+ int repeat = intArg(args, "repeat", 0);
+ selectChaosProcess(ctx, args);
+ ChaosExperimentRun.Feed traffic = feed != null &&
!feed.isBlank()
+ ? new ChaosExperimentRun.Feed(
+ feed, args.get("body"),
args.get("headers"), repeat,
+ args.get("interval"))
+ : null;
+ return ChaosExperimentRun.run(new
ChaosExperimentRun.Integration() {
+ @Override
+ public JsonObject chaos(String command,
Consumer<JsonObject> options) {
+ String answer = ctx.executeAction("chaos", root ->
{
+ root.put("command", command);
+ options.accept(root);
+ });
+ return Jsoner.deserialize(answer, new
JsonObject());
+ }
+
+ @Override
+ public JsonObject send(ChaosExperimentRun.Feed f) {
+ return ctx.sendMessage(f.endpoint(), f.body(),
f.headers(), f.repeat(), f.interval());
+ }
+
+ @Override
+ public JsonObject status() {
+ return ctx.readFullStatus();
+ }
+ }, args.get("hypothesis"), rule, traffic, wait,
args.get("note")).toJson();
+ }));
+
+ register(tool("chaos_explore_finish",
+ "Finish the chaos exploration of the integration before its
deadline (as camel cmd chaos-explore-finish):"
+ + " the rules are removed (an
open experiment is ended), and the journal "
+ + "stays in chaos_report.
Returns the journal: the rules tried, the "
+ + "experiments with their
verdicts, and the baseline.")
+ .param("name", "string", CHAOS_NAME, false)
+ .readOnly(false).destructive(false)
+ .executor((ctx, args) -> {
+ selectChaosProcess(ctx, args);
+ return chaos(ctx, "explore-finish", null, null);
+ }));
+ }
+
+ private static void putIfSet(JsonObject root, String key, String value) {
+ if (value != null && !value.isBlank()) {
+ root.put(key, value);
+ }
}
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
index 0020ea06cf3e..1d883f794194 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/AskToolsTest.java
@@ -238,11 +238,32 @@ class AskToolsTest {
// the user sees the rules of the scenario
assertThat(load.target()).isEqualTo("# outage\nendpoint=http:* error");
for (String name : List.of("chaos_check", "chaos_suggest",
"chaos_remove", "chaos_clear", "chaos_list",
- "chaos_report")) {
+ "chaos_report", "chaos_explore_finish")) {
assertThat(tools.confirmation(name, Map.of("id",
"r1"))).as(name).isNull();
}
}
+ @Test
+ void anExplorationIsAskedOnceAndItsExperimentsNot() {
+ AskTools tools = new AskTools(-1, call -> true);
+
+ // the consent to the experiments in it, which camel-chaos ends at its
deadline
+ ToolConfirms.Call explore = tools.confirmation("chaos_explore_start",
+ Map.of("name", "shop", "budget", "30m", "focus", "refunds"));
+ assertThat(explore.title()).isEqualTo("explore chaos in shop for 30m");
+ assertThat(explore.target()).isEqualTo("focus: refunds");
+ assertThat(tools.confirmation("chaos_explore_start", Map.of()).title())
+ .isEqualTo("explore chaos in the selected integration for
10m");
+ // an experiment adds its rules without asking, and its messages to a
route stay inside the integration
+ assertThat(tools.confirmation("chaos_experiment",
+ Map.of("rule", "route=refunds error expect=refunds:failed<=0",
"feed", "refunds"))).isNull();
+ assertThat(tools.confirmation("chaos_experiment", Map.of("rule",
"route=refunds error"))).isNull();
+ // a message that leaves the integration is asked, as send_message
asks it
+ assertThat(tools.confirmation("chaos_experiment",
+ Map.of("rule", "route=refunds error", "feed",
"kafka:refunds")).title())
+ .isEqualTo("send a message to an endpoint the integration does
not have");
+ }
+
@Test
void theChaosGroupNamesTheSharedTools() {
AppFeatures chaos = AppFeatures.fromStatus(new
JsonObject(Map.of("chaos", Map.of("enabled", true))));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExploreToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExploreToolsTest.java
new file mode 100644
index 000000000000..ef6ff23e408c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExploreToolsTest.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.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The tools of a chaos exploration (CAMEL-26786) against an integration that
answers their actions: the exploration
+ * starts and finishes, and an experiment runs in one call, with its traffic
and the wait for its verdict in the status.
+ */
+class ChaosExploreToolsTest {
+
+ private static final long PID = 4713L;
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+ private FakeIntegration integration;
+
+ @BeforeEach
+ void start() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ integration = new FakeIntegration(PID, ChaosExploreToolsTest::answer);
+ }
+
+ @AfterEach
+ void stop() throws Exception {
+ integration.close();
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ /** The answers of an integration with chaos on, to the chaos commands and
the send. */
+ private static Object answer(JsonObject action) {
+ if ("send".equals(action.getString("action"))) {
+ return Map.of("status", "failed", "endpoint", "direct://refunds",
"messages", 10, "sent", 10, "failed", 2);
+ }
+ return switch (action.getString("command")) {
+ case "explore-start" -> Map.of("running", true, "budget", 300_000,
"focus", "refunds");
+ case "explore-finish" -> Map.of("running", false, "finishedBy",
"finished", "rules", List.of("r1"));
+ case "experiment-begin" -> experiment("PENDING", true);
+ case "experiment-end" -> experiment("FAIL", false);
+ default -> Map.of("status", "failed", "error", "Unknown chaos
command");
+ };
+ }
+
+ private static Map<String, Object> experiment(String verdict, boolean
open) {
+ return Map.of("number", 1, "hypothesis", "a refund never fails",
"verdict", verdict, "open", open,
+ "rules", List.of("r1"), "expectations",
+ List.of(Map.of("expect", "refunds:failed<=0", "state", open ?
"pending" : "unmet", "actual", 2)));
+ }
+
+ private static Object call(String tool, Map<String, String> args) {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID);
+ return ToolRegistry.execute(tool, ctx, new HashMap<>(args));
+ }
+
+ private JsonObject lastChaosCommand(String command) {
+ return integration.actions().stream().filter(a ->
command.equals(a.getString("command"))).reduce((a, b) -> b)
+ .orElseThrow();
+ }
+
+ @Test
+ void anExplorationStartsAndFinishes() throws Exception {
+ String started = call("chaos_explore_start", Map.of("budget", "5m",
"focus", "refunds")).toString();
+
+ assertThat(started).contains("\"running\":true");
+ JsonObject action = lastChaosCommand("explore-start");
+ assertThat(action.getString("action")).isEqualTo("chaos");
+ assertThat(action.getString("budget")).isEqualTo("5m");
+ assertThat(action.getString("focus")).isEqualTo("refunds");
+
+ assertThat(call("chaos_explore_finish",
Map.of()).toString()).contains("\"finishedBy\":\"finished\"");
+ }
+
+ @Test
+ void anExperimentInOneCall() throws Exception {
+ // the status shows the verdict decided: the wait ends at the first
look
+ JsonObject exploration = new JsonObject(
+ Map.of("running", true, "timeLeft", 240_000,
+ "experiments", List.of(experiment("FAIL", true))));
+ JsonObject status = new JsonObject(
+ Map.of("chaos",
+ Map.of("enabled", true, "report",
Map.of("exploration", exploration))));
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(PID +
"-status.json"), status.toJson());
+
+ String answer = call("chaos_experiment", Map.of(
+ "hypothesis", "a refund never fails",
+ "rule", "route=refunds error rate=every:5
expect=refunds:failed<=0 within=20s",
+ "feed", "refunds", "body", "R-1", "repeat", "10", "interval",
"100ms",
+ "note", "no error handler")).toString();
+
+ JsonObject result = (JsonObject) Jsoner.deserialize(answer);
+ assertThat(result.getString("verdict")).isEqualTo("FAIL");
+ assertThat(result.getLong("timeLeft")).isEqualTo(240_000);
+ assertThat(((JsonObject)
result.get("traffic")).getLong("sent")).isEqualTo(10);
+ // begin, the messages, and the end with the note
+ assertThat(integration.actions()).extracting(a ->
a.getString("command") != null
+ ? a.getString("command") : a.getString("action"))
+ .containsExactly("experiment-begin", "send", "experiment-end");
+ JsonObject begin = lastChaosCommand("experiment-begin");
+ assertThat(begin.getString("hypothesis")).isEqualTo("a refund never
fails");
+ assertThat(begin.getString("rule")).startsWith("route=refunds error");
+ JsonObject send = integration.actions().get(1);
+ assertThat(send.getString("endpoint")).isEqualTo("refunds");
+ assertThat(send.getLong("repeat")).isEqualTo(10);
+ assertThat(send.getLong("interval")).isEqualTo(100);
+
assertThat(lastChaosCommand("experiment-end").getString("note")).isEqualTo("no
error handler");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
index 6b9776a79d95..bc995052c660 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
@@ -144,18 +144,22 @@ class ToolGroupsTest {
}
@Test
- void chaosLoadsItsNineToolsWhenOnAndOnlyTheReadsWhenOff() {
+ void chaosLoadsItsTwelveToolsWhenOnAndOnlyTheReadsWhenOff() {
AppFeatures on = AppFeatures.fromStatus(new JsonObject(Map.of("chaos",
Map.of("enabled", true))));
ToolGroups.Selection s = ToolGroups.select(on);
assertEquals(List.of(ToolGroup.CHAOS), s.toolGroups());
assertEquals(List.of("camel_runtime_chaos_list",
"camel_runtime_chaos_report", "camel_runtime_chaos_suggest",
"camel_runtime_chaos_check", "camel_runtime_chaos_add",
"camel_runtime_chaos_change",
- "camel_runtime_chaos_remove", "camel_runtime_chaos_clear",
"camel_runtime_chaos_load"), s.mcpTools());
+ "camel_runtime_chaos_remove", "camel_runtime_chaos_clear",
"camel_runtime_chaos_load",
+ "camel_runtime_chaos_explore_start",
"camel_runtime_chaos_experiment",
+ "camel_runtime_chaos_explore_finish"), s.mcpTools());
assertEquals("Chaos: camel-chaos is on, so failures may be injected on
purpose; camel_runtime_chaos_list and"
+ " camel_runtime_chaos_report show the rules and what
they did; camel_runtime_chaos_add takes a rule"
+ " such as endpoint=kafka:orders* error rate=0.1
until=5m, and camel_runtime_chaos_check says what it"
+ " would hit without adding it;
camel_runtime_chaos_suggest suggests rules from what the routes do,"
- + " and camel_runtime_chaos_load loads a scenario.",
+ + " and camel_runtime_chaos_load loads a scenario;
camel_runtime_chaos_explore_start starts an"
+ + " exploration with a time budget, in which
camel_runtime_chaos_experiment runs one experiment per"
+ + " call (rules, traffic and the wait for the verdict),
until camel_runtime_chaos_explore_finish.",
s.guidance().get(0));
assertEquals("chaos|", s.fingerprint());
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
index 8fa9bb960510..00fcce3a0d7d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
@@ -321,12 +321,12 @@ class ToolRegistryTest {
assertTrue(t.isReadOnly(), name);
assertFalse(t.isDestructive(), name);
}
- for (String name : List.of("chaos_add", "chaos_change", "chaos_load"))
{
+ for (String name : List.of("chaos_add", "chaos_change", "chaos_load",
"chaos_experiment")) {
ToolDescriptor t = ToolRegistry.findTool(name);
assertFalse(t.isReadOnly(), name);
assertTrue(t.isDestructive(), name);
}
- for (String name : List.of("chaos_remove", "chaos_clear")) {
+ for (String name : List.of("chaos_remove", "chaos_clear",
"chaos_explore_start", "chaos_explore_finish")) {
ToolDescriptor t = ToolRegistry.findTool(name);
assertFalse(t.isReadOnly(), name);
assertFalse(t.isDestructive(), name);
@@ -349,6 +349,17 @@ class ToolRegistryTest {
assertEquals("scenario is required",
assertThrows(ToolExecutionException.class,
() -> ToolRegistry.execute("chaos_load", new ToolContext(),
Map.of("scenarioName", "outage")))
.getMessage());
+ assertEquals("rule is required",
assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("chaos_experiment", new
ToolContext(), Map.of("feed", "orders")))
+ .getMessage());
+ assertEquals("The wait must be at most 10m, was: 1h",
assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("chaos_experiment", new
ToolContext(),
+ Map.of("rule", "route=orders error
expect=orders:failed<=0", "wait", "1h")))
+ .getMessage());
+ assertTrue(assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("chaos_experiment", new
ToolContext(),
+ Map.of("rule", "route=orders error
expect=orders:failed<=0", "interval", "500..100")))
+ .getMessage().contains("from the shortest to the longest"));
}
@Test
@@ -388,7 +399,7 @@ class ToolRegistryTest {
// only the chaos tools that change the rules, the check (the
integration parses the rule) and what to try (an
// action of the CLI connector) stay off HTTP; the other tools keep
what they offered
assertEquals(List.of("chaos_add", "chaos_check", "chaos_suggest",
"chaos_load", "chaos_change", "chaos_remove",
- "chaos_clear"), left);
+ "chaos_clear", "chaos_explore_start", "chaos_experiment",
"chaos_explore_finish"), left);
assertTrue(http.containsAll(List.of("chaos_list", "chaos_report",
"stop_route", "execute_sql")));
}
}
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 1f7f8e290b26..95e1d96f308f 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
@@ -1266,6 +1266,90 @@ public class RuntimeTools {
Boolean.TRUE.equals(reset) ? Map.of("reset", "true") :
Map.of());
}
+ @ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = false,
destructiveHint = false, openWorldHint = false),
+ description = """
+ Start a chaos exploration in the Camel application (as camel
cmd chaos-explore-start): a time to \
+ try chaos rules in, that camel-chaos enforces. While it
runs, a rule ends at the deadline at the \
+ latest, and at the deadline the rules are removed, whatever
happens. Its journal (the baseline, \
+ the rules tried and the experiments) is in
camel_runtime_chaos_report. Run experiments in it with \
+ camel_runtime_chaos_experiment, and finish it with
camel_runtime_chaos_explore_finish. One runs \
+ at a time.""")
+ public JsonObject camel_runtime_chaos_explore_start(
+ @ToolArg(description = CHAOS_NAME_OR_PID_DESC, required = false)
String nameOrPid,
+ @ToolArg(description = "How long it runs, between 10s and 2h
(default 10m)", required = false) String budget,
+ @ToolArg(description = "What it is about, such as a route",
required = false) String focus) {
+ Map<String, String> args = new HashMap<>();
+ putIfSet(args, "budget", budget);
+ putIfSet(args, "focus", focus);
+ return delegateChaos("chaos_explore_start", nameOrPid, args);
+ }
+
+ @ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = false,
destructiveHint = true, openWorldHint = false),
+ description = """
+ Run one chaos experiment in the running exploration of the
Camel application (as camel cmd \
+ chaos-experiment), in one call: it adds the rules (one of
them with an expectation, expect=, that \
+ decides the hypothesis), sends the messages of feed when
given (as camel_runtime_send, with body, \
+ headers, repeat and interval), waits until the expectations
are decided or wait is up, and ends \
+ the experiment, which removes its rules. Returns the verdict
(FAIL: an expectation is unmet; PASS: \
+ all are met; INCONCLUSIVE: the wait was up before one was
decided, so wait longer than its \
+ within=), the expectations with their actual values, the
faults per rule, what the routes did \
+ meanwhile, the traffic sent, and the time left of the
exploration (timeLeft, millis). \
+ camel_runtime_chaos_suggest has rules to try and the feed of
each route.""")
+ public JsonObject camel_runtime_chaos_experiment(
+ @ToolArg(description = CHAOS_NAME_OR_PID_DESC, required = false)
String nameOrPid,
+ @ToolArg(description = "The rules in their one-line text form,
separated by ;, one of them with expect=,"
+ + " 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",
+ 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,
+ @ToolArg(description = "The headers of the messages as key=value
pairs separated by newlines",
+ required = false) String headers,
+ @ToolArg(description = "How many messages to send in all (at most
10000)", required = false) Integer repeat,
+ @ToolArg(description = "The pause between two messages in millis,
or a range such as 100..500",
+ required = false) String interval,
+ @ToolArg(description = "The longest wait for the verdict after the
messages were sent, such as 90s"
+ + " (default 2m, at most 10m)",
+ required = false) String wait,
+ @ToolArg(description = "What to note with the experiment in the
journal", required = false) String note) {
+ requireArg("rule", rule);
+ Map<String, String> args = new HashMap<>();
+ args.put("rule", rule);
+ putIfSet(args, "hypothesis", hypothesis);
+ putIfSet(args, "feed", feed);
+ putIfSet(args, "body", body);
+ putIfSet(args, "headers", headers);
+ if (repeat != null) {
+ args.put("repeat", repeat.toString());
+ }
+ putIfSet(args, "interval", interval);
+ putIfSet(args, "wait", wait);
+ putIfSet(args, "note", note);
+ return delegateChaos("chaos_experiment", nameOrPid, args);
+ }
+
+ @ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = false,
destructiveHint = false, openWorldHint = false),
+ description = """
+ Finish the chaos exploration of the Camel application before
its deadline (as camel cmd \
+ chaos-explore-finish): the rules are removed (an open
experiment is ended), and the journal stays \
+ in camel_runtime_chaos_report. Returns the journal: the
rules tried, the experiments with their \
+ verdicts, and the baseline.""")
+ public JsonObject camel_runtime_chaos_explore_finish(
+ @ToolArg(description = CHAOS_NAME_OR_PID_DESC, required = false)
String nameOrPid) {
+ return delegateChaos("chaos_explore_finish", nameOrPid, Map.of());
+ }
+
+ private static void putIfSet(Map<String, String> args, String key, String
value) {
+ if (value != null && !value.isBlank()) {
+ args.put(key, value);
+ }
+ }
+
/**
* As {@link #delegateToRegistry(String, String, Map)}, for a chaos tool:
without a name, the only integration
* running, else the only one with chaos switched on ({@link
RuntimeService#findChaosProcess(String)}).
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosSecurityTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosSecurityTest.java
index 510069c74919..2c5bbd2b0468 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosSecurityTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosSecurityTest.java
@@ -34,8 +34,9 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* The marks of the chaos tools and what they mean: adding and changing a rule
is destructive and asked first
- * ({@link ToolConfirms}), the list and the report only read, and each access
level of the security layer
- * ({@link McpAccessFilter} decides from the annotations) offers the tools by
their marks.
+ * ({@link ToolConfirms}), an experiment is asked once with the start of its
exploration, the list and the report only
+ * read, and each access level of the security layer ({@link McpAccessFilter}
decides from the annotations) offers the
+ * tools by their marks.
*/
class RuntimeToolsChaosSecurityTest {
@@ -52,6 +53,10 @@ class RuntimeToolsChaosSecurityTest {
MARKS.put("camel_runtime_chaos_remove", new boolean[] { false, false
});
MARKS.put("camel_runtime_chaos_clear", new boolean[] { false, false });
MARKS.put("camel_runtime_chaos_load", new boolean[] { false, true });
+ // an exploration bounds the faults by its deadline; an experiment
adds rules, so it is destructive
+ MARKS.put("camel_runtime_chaos_explore_start", new boolean[] { false,
false });
+ MARKS.put("camel_runtime_chaos_experiment", new boolean[] { false,
true });
+ MARKS.put("camel_runtime_chaos_explore_finish", new boolean[] { false,
false });
}
private static Tool.Annotations annotations(String name) {
@@ -89,11 +94,14 @@ class RuntimeToolsChaosSecurityTest {
void exactlyTheDestructiveToolsAreInToolConfirms() {
MARKS.forEach((name, marks) -> {
String command = name.substring("camel_runtime_chaos_".length());
- ToolConfirms.Call call = "load".equals(command)
- ? ToolConfirms.chaosLoad("shop", "outage",
"endpoint=kafka:* error rate=0.1")
- : ToolConfirms.chaos(command, "shop", "r1",
"endpoint=kafka:* error rate=0.1");
+ ToolConfirms.Call call = switch (command) {
+ case "load" -> ToolConfirms.chaosLoad("shop", "outage",
"endpoint=kafka:* error rate=0.1");
+ // asked once, when its exploration starts
(chaos_explore_start), for all its experiments
+ case "experiment" -> ToolConfirms.chaosExplore("shop", "10m",
null);
+ default -> ToolConfirms.chaos(command, "shop", "r1",
"endpoint=kafka:* error rate=0.1");
+ };
assertThat(call != null).as(name + " is asked
first").isEqualTo(marks[1]);
- if (call != null) {
+ if (call != null && !"experiment".equals(command)) {
// the user sees the rule, or the rules of the scenario
assertThat(call.target()).isEqualTo("endpoint=kafka:* error
rate=0.1");
}
@@ -109,7 +117,8 @@ class RuntimeToolsChaosSecurityTest {
assertThat(offered(McpSecurityConfig.AccessLevel.READ_WRITE)).containsExactly(
"camel_runtime_chaos_list", "camel_runtime_chaos_report",
"camel_runtime_chaos_suggest",
"camel_runtime_chaos_check",
- "camel_runtime_chaos_remove", "camel_runtime_chaos_clear");
+ "camel_runtime_chaos_remove", "camel_runtime_chaos_clear",
"camel_runtime_chaos_explore_start",
+ "camel_runtime_chaos_explore_finish");
assertThat(offered(McpSecurityConfig.AccessLevel.ADMIN)).containsExactlyElementsOf(MARKS.keySet());
// with the security layer on, the interceptor of @McpSecured audits
each call (and redacts the answer when
// camel.mcp.security.redaction.enabled=true)