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 4b061cb959aa CAMEL-26720: camel-chaos - chaos-add --dry-run and a
check tool say what a rule would hit, without adding it (#29390)
4b061cb959aa is described below
commit 4b061cb959aaa6542c835c5a56f41f5cdebc04f8
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Nov 7 22:05:41 2026 +0100
CAMEL-26720: camel-chaos - chaos-add --dry-run and a check tool say what a
rule would hit, without adding it (#29390)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/chaos.adoc | 27 +++-
components/camel-chaos/src/main/docs/chaos.adoc | 27 +++-
.../apache/camel/component/chaos/ChaosManager.java | 61 +++++++++
.../apache/camel/component/chaos/ChaosTargets.java | 104 +++++++++++++++
.../camel/component/chaos/ChaosCheckTest.java | 142 +++++++++++++++++++++
design/chaos.adoc | 3 +-
.../modules/ROOT/pages/camel-jbang-managing.adoc | 4 +-
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 12 +-
.../jbang-commands/camel-jbang-cmd-chaos-add.adoc | 1 +
.../apache/camel/cli/connector/ChaosAction.java | 12 +-
.../LocalCliConnectorChaosActionTest.java | 22 ++++
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../camel/dsl/jbang/core/commands/AskTools.java | 1 +
.../core/commands/action/CamelChaosAddAction.java | 73 ++++++++++-
.../dsl/jbang/core/commands/ai/ToolGroups.java | 8 +-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 33 ++++-
.../dsl/jbang/core/commands/AskToolsTest.java | 2 +-
.../core/commands/action/CamelChaosActionTest.java | 36 ++++++
.../dsl/jbang/core/commands/ai/ToolGroupsTest.java | 10 +-
.../jbang/core/commands/ai/ToolRegistryTest.java | 7 +-
.../core/commands/mcp/JbangDevMcpServerTest.java | 2 +-
.../src/test/resources/golden/cli-help.txt | 10 +-
.../dsl/jbang/core/commands/mcp/RuntimeTools.java | 14 ++
.../mcp/RuntimeToolsChaosSecurityTest.java | 7 +-
.../core/commands/mcp/RuntimeToolsChaosTest.java | 7 +
.../jbang/core/commands/mcp/RuntimeToolsTest.java | 2 +-
26 files changed, 588 insertions(+), 41 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index 3ead495e3743..ba00e70049de 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -145,6 +145,23 @@ names some of the endpoints, routes or steps there are. It
is not an error, as t
creates its endpoints when it sends, and a route can be added later). The
rules of `camel.chaos.rules` are checked when
the context has started.
+To see what a rule would hit before it is added, `chaos-add --dry-run` checks
it as `chaos-add` would, and adds
+nothing. It prints the rule in plain words, then each send, poll, route or
step it matches now, with its route (and the
+step that sends), the sends known only when they send that may match (a
`toD`), and the warnings:
+
+[source]
+----
+$ camel cmd chaos-add --dry-run --rule='endpoint=http:* in=orders error
status=503'
+Would add: endpoint=http:* in=orders error status=503
+ fail every send to http:* in route orders with HTTP status 503
+ send http://inventory/stock in route orders (step callStock)
+(dry run: nothing was added)
+----
+
+A rule that is not valid is refused with the same reason as for `chaos-add`. A
condition (`when=`) is checked on each
+message as it comes, so the check lists what the rule would hit if the
messages met it, and the send of the dead letter
+channel (`at=dlq`) is not listed, as it is the error handler's.
+
The commands go through the `chaos` action of the CLI connector, which calls
`ChaosManager`: a rule goes through the
same parser as `camel.chaos.rules`, and one that is not valid is refused with
the reason. They work only when chaos is
switched on in the integration (see <<_switching_it_on,Switching it on>>):
they cannot switch it on, and with profile
@@ -155,9 +172,10 @@ JSON. It only reads: a rule cannot be added, changed or
removed over HTTP.
=== From an AI agent (MCP)
-The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel
MCP server]) has the same six operations
+The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel
MCP server]) has the same operations
as tools, for an AI agent that tries a route against failures:
`camel_runtime_chaos_list`, `camel_runtime_chaos_report`,
-`camel_runtime_chaos_add`, `camel_runtime_chaos_change`,
`camel_runtime_chaos_remove` and `camel_runtime_chaos_clear`.
+`camel_runtime_chaos_check` (the dry run), `camel_runtime_chaos_add`,
`camel_runtime_chaos_change`,
+`camel_runtime_chaos_remove` and `camel_runtime_chaos_clear`.
Without `nameOrPid` they pick the integration as the commands do: the only one
that runs, or else the only one with
chaos switched on, and otherwise they fail with the list of the running
integrations. They take the same one-line
rules: adding, changing, removing and clearing go through the same `chaos`
action of the CLI connector, and the list and the report read the console from
the status of the application. So chaos must be switched
@@ -165,9 +183,10 @@ on, and a rule that is not valid is refused with the
reason; `camel_runtime_chao
`chaos-clear --reset` does. `camel_runtime_chaos_add` takes several rules
separated by `;`, and the add and change
tools return the warnings of a rule that matches nothing, so an agent can fix
a typo with a change. Adding and changing a rule are marked destructive, so a
client that honours `destructiveHint` asks before it runs them, and the
`read-write` access level of the MCP security
-layer does not offer them; the list and the report are read-only. `camel ask`
has the same tools, and asks before it
+layer does not offer them; the list, the report and the check are read-only,
so an agent can check a rule it made from
+a loose request (what it would hit, in plain words) and show it before it asks
to add it. `camel ask` has the same tools, and asks before it
adds or changes a rule. The development MCP tools that `camel run --mcp`
(`camel.jbang.mcp=true`) serves over HTTP have
-only the list and the report.
+only the list and the report (not the check either: the integration parses the
rule, and `exception=` loads a class).
== Rules
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc
b/components/camel-chaos/src/main/docs/chaos.adoc
index 3ead495e3743..ba00e70049de 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -145,6 +145,23 @@ names some of the endpoints, routes or steps there are. It
is not an error, as t
creates its endpoints when it sends, and a route can be added later). The
rules of `camel.chaos.rules` are checked when
the context has started.
+To see what a rule would hit before it is added, `chaos-add --dry-run` checks
it as `chaos-add` would, and adds
+nothing. It prints the rule in plain words, then each send, poll, route or
step it matches now, with its route (and the
+step that sends), the sends known only when they send that may match (a
`toD`), and the warnings:
+
+[source]
+----
+$ camel cmd chaos-add --dry-run --rule='endpoint=http:* in=orders error
status=503'
+Would add: endpoint=http:* in=orders error status=503
+ fail every send to http:* in route orders with HTTP status 503
+ send http://inventory/stock in route orders (step callStock)
+(dry run: nothing was added)
+----
+
+A rule that is not valid is refused with the same reason as for `chaos-add`. A
condition (`when=`) is checked on each
+message as it comes, so the check lists what the rule would hit if the
messages met it, and the send of the dead letter
+channel (`at=dlq`) is not listed, as it is the error handler's.
+
The commands go through the `chaos` action of the CLI connector, which calls
`ChaosManager`: a rule goes through the
same parser as `camel.chaos.rules`, and one that is not valid is refused with
the reason. They work only when chaos is
switched on in the integration (see <<_switching_it_on,Switching it on>>):
they cannot switch it on, and with profile
@@ -155,9 +172,10 @@ JSON. It only reads: a rule cannot be added, changed or
removed over HTTP.
=== From an AI agent (MCP)
-The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel
MCP server]) has the same six operations
+The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel
MCP server]) has the same operations
as tools, for an AI agent that tries a route against failures:
`camel_runtime_chaos_list`, `camel_runtime_chaos_report`,
-`camel_runtime_chaos_add`, `camel_runtime_chaos_change`,
`camel_runtime_chaos_remove` and `camel_runtime_chaos_clear`.
+`camel_runtime_chaos_check` (the dry run), `camel_runtime_chaos_add`,
`camel_runtime_chaos_change`,
+`camel_runtime_chaos_remove` and `camel_runtime_chaos_clear`.
Without `nameOrPid` they pick the integration as the commands do: the only one
that runs, or else the only one with
chaos switched on, and otherwise they fail with the list of the running
integrations. They take the same one-line
rules: adding, changing, removing and clearing go through the same `chaos`
action of the CLI connector, and the list and the report read the console from
the status of the application. So chaos must be switched
@@ -165,9 +183,10 @@ on, and a rule that is not valid is refused with the
reason; `camel_runtime_chao
`chaos-clear --reset` does. `camel_runtime_chaos_add` takes several rules
separated by `;`, and the add and change
tools return the warnings of a rule that matches nothing, so an agent can fix
a typo with a change. Adding and changing a rule are marked destructive, so a
client that honours `destructiveHint` asks before it runs them, and the
`read-write` access level of the MCP security
-layer does not offer them; the list and the report are read-only. `camel ask`
has the same tools, and asks before it
+layer does not offer them; the list, the report and the check are read-only,
so an agent can check a rule it made from
+a loose request (what it would hit, in plain words) and show it before it asks
to add it. `camel ask` has the same tools, and asks before it
adds or changes a rule. The development MCP tools that `camel run --mcp`
(`camel.jbang.mcp=true`) serves over HTTP have
-only the list and the report.
+only the list and the report (not the check either: the integration parses the
rule, and `exception=` loads a class).
== Rules
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index 867de4d2ffde..87c4bcdf578d 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -23,6 +23,7 @@ import java.util.Collection;
import java.util.Deque;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -39,6 +40,8 @@ import org.apache.camel.component.chaos.ChaosRule.Hook;
import org.apache.camel.component.chaos.ChaosRule.State;
import org.apache.camel.support.ScheduledPollConsumer;
import org.apache.camel.util.TimeSource;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -284,6 +287,64 @@ public final class ChaosManager {
}
rule = rules.get(index);
}
+ return warnings(rule);
+ }
+
+ /**
+ * Checks rules without adding them ({@code chaos-add --dry-run}, the
chaos_check tool): each is parsed and checked
+ * as {@link #addRules(String)} does, so a rule that is not valid is
refused with the same reason, and nothing
+ * changes in the context. For each rule, in their order: the rule, the
rule in plain words at the hooks where it
+ * can fire now, those hooks, the warnings of a rule that matches nothing
now, and what it would hit now (the sends,
+ * polls, routes or steps, each with its route; see {@link
ChaosTargets#hits}).
+ *
+ * @param rules the rules in their one-line text form,
separated by {@code ;}
+ * @return {@code {"rules": [...]}}, as JSON
+ * @throws IllegalArgumentException when a rule is not valid, with the
reason
+ */
+ public Map<String, Object> checkRules(String rules) {
+ List<String> texts = ChaosRuleParser.splitRules(rules);
+ if (texts.isEmpty()) {
+ throw new IllegalArgumentException("The chaos rule is empty");
+ }
+ List<ChaosRule.Builder> definitions = new ArrayList<>();
+ for (String text : texts) {
+ ChaosRule.Builder definition = parse(text);
+ checkRoute(definition);
+ definitions.add(definition);
+ }
+ long now = TimeSource.currentTimeMillis();
+ ChaosTargets.Reach reach = ChaosTargets.reach(camelContext);
+ JsonArray checked = new JsonArray();
+ for (ChaosRule.Builder definition : definitions) {
+ // a rule of its own, which no hook sees: its id is only for the
words of the warnings
+ ChaosRule rule = new ChaosRule(definition, "check", seed, now,
maxDuration);
+ Set<Hook> hooks = ChaosTargets.hooksThatCanFire(reach, rule);
+ JsonObject jo = new JsonObject();
+ jo.put("rule", rule.text);
+ jo.put("description", rule.describe(hooks));
+ JsonArray names = new JsonArray();
+ hooks.forEach(h -> names.add(h.key));
+ jo.put("hooks", names);
+ List<String> warnings = warnings(rule);
+ if (!warnings.isEmpty()) {
+ jo.put("warnings", new JsonArray(warnings));
+ }
+ if (camelContext.isStarted()) {
+ jo.put("hits", ChaosTargets.hits(camelContext, rule));
+ }
+ if (rule.when != null) {
+ // the condition is checked on each message as it comes
+ jo.put("when", rule.when);
+ }
+ checked.add(jo);
+ }
+ JsonObject answer = new JsonObject();
+ answer.put("rules", checked);
+ return answer;
+ }
+
+ /** The warnings of a rule that matches nothing in the context now (none
before the context started). */
+ private List<String> warnings(ChaosRule rule) {
List<String> answer = new ArrayList<>();
if (!camelContext.isStarted()) {
return answer;
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosTargets.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosTargets.java
index 6cb85d17c154..8d9bc3631f12 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosTargets.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosTargets.java
@@ -48,6 +48,8 @@ import org.apache.camel.model.language.ExpressionDefinition;
import org.apache.camel.spi.IdAware;
import org.apache.camel.support.ScheduledPollConsumer;
import org.apache.camel.util.URISupport;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -81,6 +83,108 @@ final class ChaosTargets {
return answer == null && rule.in != null ? checkIn(camelContext, rule)
: answer;
}
+ /**
+ * What a rule would hit in the context now, for a check before it is
added ({@code chaos-add --dry-run}): the sends
+ * of the routes to a matching endpoint (with the route and the step that
sends), the sends known only when they
+ * send that may match, the polls of a matching endpoint, the routes of a
{@code route=} target, or the steps of a
+ * {@code step=} target, each in the routes of {@code in=} when it has
one. At most {@link #HITS} of each, with
+ * {@code more} for the rest. The send of the dead letter channel is not
listed, as it is the error handler's.
+ */
+ static JsonObject hits(CamelContext camelContext, ChaosRule rule) {
+ JsonObject answer = new JsonObject();
+ List<Route> routes;
+ try {
+ routes = new ArrayList<>(camelContext.getRoutes());
+ } catch (RuntimeException e) {
+ return answer;
+ }
+ int[] more = new int[1];
+ JsonArray sends = new JsonArray();
+ JsonArray dynamic = new JsonArray();
+ JsonArray polls = new JsonArray();
+ JsonArray routeHits = new JsonArray();
+ JsonArray steps = new JsonArray();
+ boolean endpoints = rule.target == ChaosRule.Target.ENDPOINT ||
rule.target == ChaosRule.Target.COMPONENT;
+ boolean atSends = rule.hooks.contains(Hook.SEND) ||
rule.hooks.contains(Hook.REPLY);
+ for (Route route : routes) {
+ String routeId = route.getRouteId();
+ if (rule.target == ChaosRule.Target.ROUTE) {
+ if (rule.matchesId(routeId)) {
+ add(routeHits, more, hit("route", routeId, "from",
withoutQuery(endpointUri(route.getEndpoint()))));
+ }
+ continue;
+ }
+ if (!rule.matchesIn(routeId)) {
+ continue;
+ }
+ if (endpoints && rule.hooks.contains(Hook.POLL) &&
route.getConsumer() instanceof ScheduledPollConsumer
+ && rule.matchesUri(endpointUri(route.getEndpoint()))) {
+ add(polls, more, hit("route", routeId, "endpoint",
withoutQuery(endpointUri(route.getEndpoint()))));
+ }
+ for (Processor processor : route.filter("*")) {
+ String id = processor instanceof IdAware idAware ?
idAware.getId() : null;
+ if (rule.target == ChaosRule.Target.STEP) {
+ if (id != null && rule.matchesId(id)) {
+ add(steps, more, hit("route", routeId, "step", id));
+ }
+ } else if (endpoints && atSends && processor instanceof
EndpointAware aware && aware.getEndpoint() != null
+ &&
rule.matchesUri(ChaosPatterns.endpointUri(aware.getEndpoint()))) {
+ JsonObject send = hit("route", routeId, "endpoint",
withoutQuery(endpointUri(aware.getEndpoint())));
+ if (id != null) {
+ send.put("step", id);
+ }
+ add(sends, more, send);
+ }
+ }
+ if (endpoints && atSends && route.getRoute() instanceof
RouteDefinition definition) {
+ List<String> uris = new ArrayList<>();
+ dynamicSends(definition.getOutputs(), uris);
+ for (String uri : uris) {
+ if (mayMatch(rule, uri)) {
+ add(dynamic, more, hit("route", routeId, "uri",
withoutQuery(uri)));
+ }
+ }
+ }
+ }
+ putIfAny(answer, "sends", sends);
+ putIfAny(answer, "dynamicSends", dynamic);
+ putIfAny(answer, "polls", polls);
+ putIfAny(answer, "routes", routeHits);
+ putIfAny(answer, "steps", steps);
+ if (more[0] > 0) {
+ answer.put("more", more[0]);
+ }
+ return answer;
+ }
+
+ // the most hits of each kind a check lists
+ static final int HITS = 25;
+
+ private static String endpointUri(Endpoint endpoint) {
+ return endpoint != null ? ChaosPatterns.endpointUri(endpoint) : "";
+ }
+
+ private static JsonObject hit(String k1, String v1, String k2, String v2) {
+ JsonObject jo = new JsonObject();
+ jo.put(k1, v1);
+ jo.put(k2, v2);
+ return jo;
+ }
+
+ private static void add(JsonArray hits, int[] more, JsonObject hit) {
+ if (hits.size() < HITS) {
+ hits.add(hit);
+ } else {
+ more[0]++;
+ }
+ }
+
+ private static void putIfAny(JsonObject answer, String key, JsonArray
hits) {
+ if (!hits.isEmpty()) {
+ answer.put(key, hits);
+ }
+ }
+
/**
* Why a rule with {@code in=} finds nothing to fire at in its routes: no
route matches {@code in=}, or its target
* matches nothing inside those routes (a send, a step, or the poll of the
consumer of the route). A send inside a
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosCheckTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosCheckTest.java
new file mode 100644
index 000000000000..47dd3397510b
--- /dev/null
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosCheckTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.chaos;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * {@link ChaosManager#checkRules(String)}: a rule is checked as it would be
added, and what it would hit now is listed,
+ * without adding it ({@code chaos-add --dry-run}, the chaos_check tool).
+ */
+class ChaosCheckTest extends ChaosTestSupport {
+
+ @BindToRegistry("testpoll")
+ private final TestPollComponent testPoll = new TestPollComponent();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:orders").routeId("orders")
+ .to("direct:stock").id("callStock")
+ .to("mock:shipping?retainFirst=10").id("ship")
+ .toD("mock:dyn-${header.n}").id("dyn");
+ from("direct:stock").routeId("stock")
+ .log("stock").id("logStock")
+ .to("mock:shipping").id("stockShip");
+
from("testpoll:inbox?initialDelay=3600000").routeId("poller").to("mock:in").id("toIn");
+ }
+ };
+ }
+
+ @SuppressWarnings("unchecked")
+ private JsonObject check(String rules) {
+ return new JsonObject((Map<String, Object>) chaos().checkRules(rules));
+ }
+
+ private JsonObject only(String rule) {
+ List<Object> rules = check(rule).getCollection("rules");
+ assertThat(rules).hasSize(1);
+ return (JsonObject) rules.get(0);
+ }
+
+ private static List<Object> hits(JsonObject checked, String kind) {
+ return ((JsonObject) checked.get("hits")).getCollection(kind);
+ }
+
+ @Test
+ void theSendsItWouldHitWithTheirRouteAndStep() {
+ JsonObject checked = only("endpoint=mock:shipping* error rate=0.5");
+
+
assertThat(checked.getString("rule")).isEqualTo("endpoint=mock:shipping* error
rate=0.5");
+ assertThat(checked.getString("description")).isEqualTo("fail half of
the sends to mock:shipping*");
+ assertThat((List<Object>)
checked.getCollection("hooks")).containsExactly("send");
+ // the query of an endpoint is left out (it can hold secrets)
+ assertThat(hits(checked, "sends")).containsExactly(
+ Map.of("route", "orders", "endpoint", "mock://shipping",
"step", "ship"),
+ Map.of("route", "stock", "endpoint", "mock://shipping",
"step", "stockShip"));
+ // nothing was added
+ assertThat((JsonArray)
chaos().report().toJson().get("rules")).isEmpty();
+ }
+
+ @Test
+ void inNarrowsTheHitsToTheRoute() {
+ JsonObject checked = only("endpoint=mock:shipping* in=stock error");
+ assertThat(hits(checked, "sends")).singleElement()
+ .isEqualTo(Map.of("route", "stock", "endpoint",
"mock://shipping", "step", "stockShip"));
+ }
+
+ @Test
+ void aSendKnownOnlyWhenItSendsMayMatch() {
+ JsonObject checked = only("endpoint=mock:dyn-* delay duration=1ms");
+ assertThat(hits(checked, "dynamicSends")).singleElement()
+ .isEqualTo(Map.of("route", "orders", "uri",
"mock:dyn-${header.n}"));
+ }
+
+ @Test
+ void thePollsTheRoutesAndTheSteps() {
+ assertThat(hits(only("component=testpoll drop at=poll"),
"polls")).singleElement()
+ .isEqualTo(Map.of("route", "poller", "endpoint",
"testpoll://inbox"));
+ assertThat(hits(only("route=sto* error"), "routes")).singleElement()
+ .isEqualTo(Map.of("route", "stock", "from", "direct://stock"));
+ assertThat(hits(only("step=*Stock error in=stock"),
"steps")).singleElement()
+ .isEqualTo(Map.of("route", "stock", "step", "logStock"));
+ assertThat(hits(only("route=poller stall duration=10s"),
"routes")).singleElement()
+ .isEqualTo(Map.of("route", "poller", "from",
"testpoll://inbox"));
+ }
+
+ @Test
+ void aRuleThatHitsNothingHasItsWarning() {
+ JsonObject checked = only("endpoint=mock:shiping error");
+ assertThat((JsonObject) checked.get("hits")).isEmpty();
+ assertThat((List<Object>)
checked.getCollection("warnings")).singleElement().asString()
+ .startsWith("endpoint=mock:shiping matches no endpoint of the
context now");
+ }
+
+ @Test
+ void severalRulesAndACondition() {
+ List<Object> rules = check("endpoint=mock:shipping error
at=send;route=orders error when=\"${header.a} == 'b'\"")
+ .getCollection("rules");
+ assertThat(rules).hasSize(2);
+ JsonObject second = (JsonObject) rules.get(1);
+ assertThat(second.getString("when")).isEqualTo("${header.a} == 'b'");
+ assertThat(second.getString("description")).isEqualTo("fail every
message entering route orders, when"
+ + " ${header.a}
== 'b'");
+ }
+
+ @Test
+ void aRuleThatIsNotValidIsRefusedAsAddWouldRefuseIt() {
+ assertThatThrownBy(() -> chaos().checkRules("endpoint=mock:x explode"))
+
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("unknown
fault explode");
+ assertThatThrownBy(() -> chaos().checkRules("route=nothere stall
duration=1s"))
+
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("there is no
route nothere");
+ assertThatThrownBy(() -> chaos().checkRules(" "))
+ .isInstanceOf(IllegalArgumentException.class).hasMessage("The
chaos rule is empty");
+ assertThat((JsonArray)
chaos().report().toJson().get("rules")).isEmpty();
+ }
+}
diff --git a/design/chaos.adoc b/design/chaos.adoc
index e43ee7cb79d4..7f8d504d67a0 100644
--- a/design/chaos.adoc
+++ b/design/chaos.adoc
@@ -409,7 +409,8 @@ pending is not final.
|`camel_runtime_chaos_add`, `camel_runtime_chaos_change` |`false` |`true`
|`camel_runtime_chaos_remove`, `camel_runtime_chaos_clear` |`false` |`false`
-|`camel_runtime_chaos_list`, `camel_runtime_chaos_report` |`true` |`false`
+|`camel_runtime_chaos_list`, `camel_runtime_chaos_report`,
`camel_runtime_chaos_check` (stage 2, CAMEL-26720)
+|`true` |`false`
|===
+
With `camel.mcp.security.enabled=true` and
`camel.mcp.security.access-level=read-write`, add and change are refused,
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 8e40ef2d04b7..92063c7bfa29 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -902,7 +902,9 @@ camel cmd chaos-clear
`chaos-add`, `chaos-change` and `chaos-remove` take the name or pid of the
integration; without it they act on the
only integration that runs, or the only one with chaos switched on, and
otherwise fail with the list of the running
-integrations. `chaos-add` prints the rule in plain words too (`fail 30% of the
sends to kafka:orders*, for 1 minute`).
+integrations. `chaos-add` prints the rule in plain words too (`fail 30% of the
sends to kafka:orders*, for 1 minute`),
+and with `--dry-run` it adds nothing, and prints what the rule would hit now:
the sends, polls, routes or steps, with
+their route.
`camel get chaos` lists the current rules with their id, version, state,
matches and the faults that fired, and
`--report` shows the report: the verdict, the seed, every version of every
rule and the counts per route. Chaos is for
development only: it stays off with profile `prod`, and the commands cannot
switch it on.
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 eeb434db5638..9de8f7d3463f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -1087,6 +1087,12 @@ that changes rules is refused. The list and the report
read the chaos console fr
| The report: the verdict and whether it is final, the seed, every version of
every rule with its live state, the
expectations, the counts per route and the last 20 faults.
+| `camel_runtime_chaos_check`
+| `true` | `false`
+| Checks a rule (`rule`), or several separated by `;`, without adding it, as
`camel cmd chaos-add --dry-run` does:
+ each in plain words, where it can fire, `warnings`, and what it would hit
now (`hits`: the sends, polls, routes or
+ steps, each with its route). A rule that is not valid is refused with the
reason.
+
| `camel_runtime_chaos_add`
| `false` | `true`
| Adds a rule (`rule`), or several separated by `;` (all or none), and returns
the ids (`r1`, `r2`, ...), with
@@ -1110,11 +1116,11 @@ that changes rules is refused. The list and the report
read the chaos console fr
Adding and changing a rule 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 and the report. Removing
+not offer `camel_runtime_route_control` or `camel_runtime_sql`; `read-only`
offers the list, the report 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. The development
-MCP tools that `camel run --mcp` serves over HTTP have only the list and the
report: a rule is never added, changed or
-removed over the HTTP server of the application.
+MCP tools that `camel run --mcp` serves over HTTP have only the list and the
report: a rule is never added, changed,
+removed or checked over the HTTP server of the application.
==== Tool groups for local models
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-add.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-add.adoc
index fff9e87e943e..6b27ddcf0a67 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-add.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-chaos-add.adoc
@@ -19,6 +19,7 @@ camel cmd chaos-add [options]
[cols="2,5,1,2",options="header"]
|===
| Option | Description | Default | Type
+| `--dry-run` | Check the rules without adding them: each in plain words, and
what it would hit in the integration now (the sends, polls, routes or steps,
with their route) | | boolean
| `--rule` | A rule in its one-line text form, as an entry of
camel.chaos.rules; can be given more than once | | List
| `--timeout` | How long to wait for the integration to answer, in millis or
as a duration such as 30s | 10000 | long
| `-h,--help` | Display the help and sub-commands | | boolean
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
index 9ff87bf81b58..b20646fa0ee1 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
@@ -20,6 +20,7 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import org.apache.camel.CamelContext;
import org.apache.camel.util.json.JsonArray;
@@ -80,6 +81,15 @@ final class ChaosAction {
answer.put("version", 1);
descriptions(type, chaos, ids, answer);
warnings(type, chaos, ids, answer);
+ } else if ("check".equals(command)) {
+ if (isBlank(rule)) {
+ output.error("A rule is required");
+ return;
+ }
+ // checked as add checks it, and not added: what it would hit
now, in plain words
+ @SuppressWarnings("unchecked")
+ Map<String, Object> checked = (Map<String, Object>) call(type,
chaos, "checkRules", rule);
+ answer.putAll(checked);
} else if ("change".equals(command)) {
if (isBlank(id) || isBlank(rule)) {
output.error("The id of the rule and the new rule are
required");
@@ -112,7 +122,7 @@ final class ChaosAction {
answer.put("reset", true);
}
} else {
- output.error("Unknown chaos command: " + command + " (add,
change, remove or clear)");
+ output.error("Unknown chaos command: " + command + " (add,
check, change, remove or clear)");
return;
}
} catch (IllegalArgumentException | IllegalStateException e) {
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
index b4ec117a932d..3de5ff5fd273 100644
---
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
@@ -155,6 +155,28 @@ class LocalCliConnectorChaosActionTest {
assertThat(reportRules()).isEmpty();
}
+ @Test
+ void checkSaysWhatARuleWouldHitWithoutAddingIt() throws Exception {
+ start(null, Map.of("camel.chaos.enabled", "true"));
+
+ CliActionOutcome checked = chaos("check", null, "endpoint=mock:target
error rate=0.5");
+ assertThat(checked.error()).isNull();
+ JsonObject rule = (JsonObject)
checked.result().getCollection("rules").iterator().next();
+ assertThat(rule.getString("description")).isEqualTo("fail half of the
sends to mock:target");
+ JsonObject send = (JsonObject) ((JsonObject)
rule.get("hits")).getCollection("sends").iterator().next();
+ assertThat(send.getString("route")).isEqualTo("start");
+ assertThat(send.getString("endpoint")).isEqualTo("mock://target");
+ // nothing was added, and the messages go through
+ assertThat(reportRules()).isEmpty();
+ assertThat(context.createProducerTemplate().send("direct:start", e ->
e.getMessage().setBody("x"))
+ .getException()).isNull();
+
+ // refused with the reason, as add would refuse it
+ assertThat(chaos("check", null, "endpoint=mock:target error
rate=2").error())
+ .contains("rate=2 is not a probability above 0 and at most 1");
+ assertThat(chaos("check", null, " ").error()).isEqualTo("A rule is
required");
+ }
+
@Test
void rulesAreAddedTogetherWithWarningsForTheOnesThatMatchNothing() throws
Exception {
start(null, Map.of("camel.chaos.enabled", "true"));
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 39463c0717ac..0739cd2af369 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/AskTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 4cab9fb57077..14de64363aa4 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
@@ -71,6 +71,7 @@ public class AskTools {
Map.entry(ToolGroups.HTTP_REQUEST_TOOL, "http_request"),
Map.entry(ToolGroups.CHAOS_LIST_TOOL, "chaos_list"),
Map.entry(ToolGroups.CHAOS_REPORT_TOOL, "chaos_report"),
+ Map.entry(ToolGroups.CHAOS_CHECK_TOOL, "chaos_check"),
Map.entry(ToolGroups.CHAOS_ADD_TOOL, "chaos_add"),
Map.entry(ToolGroups.CHAOS_CHANGE_TOOL, "chaos_change"),
Map.entry(ToolGroups.CHAOS_REMOVE_TOOL, "chaos_remove"),
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosAddAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosAddAction.java
index 6873634335e3..be103a2bbcce 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosAddAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelChaosAddAction.java
@@ -45,7 +45,8 @@ import picocli.CommandLine;
" camel cmd chaos-add myApp
--rule='endpoint=http:* in=orders error status=503 rate=0.1'",
" camel cmd chaos-add myApp --rule='route=orders
error rate=0.05'",
" camel cmd chaos-add myApp
--rule='endpoint=http:stock* error when=\"$${header.country} == \\\"ES\\\"\"'",
- " camel cmd chaos-add myApp
--rule='endpoint=seda:dlq error at=dlq' --rule='endpoint=http:stock* error'" })
+ " camel cmd chaos-add myApp
--rule='endpoint=seda:dlq error at=dlq' --rule='endpoint=http:stock* error'",
+ " camel cmd chaos-add myApp --dry-run
--rule='endpoint=http:* in=orders error status=503'" })
public class CamelChaosAddAction extends CamelChaosAction {
@CommandLine.Parameters(description = "Name or pid of the running Camel
integration (must match one); without it,"
@@ -58,6 +59,11 @@ public class CamelChaosAddAction extends CamelChaosAction {
+ " than once")
List<String> rules;
+ @CommandLine.Option(names = { "--dry-run" },
+ description = "Check the rules without adding them:
each in plain words, and what it would hit in"
+ + " the integration now (the sends,
polls, routes or steps, with their route)")
+ boolean dryRun;
+
public CamelChaosAddAction(CamelJBangMain main) {
super(main);
}
@@ -68,10 +74,75 @@ public class CamelChaosAddAction extends CamelChaosAction {
CamelProcess process = findChaosTarget(name);
// the rules go together, separated by ; as in camel.chaos.rules (a ;
inside a rule is quoted)
String text = String.join(";", rules);
+ if (dryRun) {
+ printer().println(describeCheck(chaos(process, "check", root ->
root.put("rule", text))));
+ return 0;
+ }
printer().println(describe(chaos(process, "add", root ->
root.put("rule", text))));
return 0;
}
+ /**
+ * The rules of a dry run: each in plain words, then what it would hit
now, by kind, and its warnings.
+ */
+ static String describeCheck(JsonObject answer) {
+ StringBuilder sb = new StringBuilder();
+ List<Object> checked = answer.getCollection("rules");
+ if (checked == null) {
+ return "The integration did not check the rules";
+ }
+ for (Object o : checked) {
+ JsonObject rule = (JsonObject) o;
+ if (!sb.isEmpty()) {
+ sb.append('\n');
+ }
+ sb.append("Would add: ").append(rule.getString("rule"));
+ String words = rule.getString("description");
+ if (words != null) {
+ sb.append("\n ").append(words);
+ }
+ JsonObject hits = (JsonObject) rule.get("hits");
+ if (hits != null) {
+ hits(sb, hits, "sends", "send", "endpoint");
+ hits(sb, hits, "dynamicSends", "send (known when it sends)",
"uri");
+ hits(sb, hits, "polls", "poll", "endpoint");
+ hits(sb, hits, "routes", "route", "from");
+ hits(sb, hits, "steps", "step", "step");
+ Integer more = hits.getInteger("more");
+ if (more != null && more > 0) {
+ sb.append("\n and ").append(more).append(" more");
+ }
+ if (hits.isEmpty()) {
+ sb.append("\n hits nothing in the integration now");
+ }
+ }
+ List<Object> warnings = rule.getCollection("warnings");
+ if (warnings != null) {
+ warnings.forEach(w -> sb.append("\n Warning: ").append(w));
+ }
+ }
+ return sb + "\n(dry run: nothing was added)";
+ }
+
+ private static void hits(StringBuilder sb, JsonObject hits, String key,
String kind, String field) {
+ List<Object> list = hits.getCollection(key);
+ if (list == null) {
+ return;
+ }
+ for (Object o : list) {
+ JsonObject hit = (JsonObject) o;
+ sb.append("\n ").append(kind).append(' ');
+ if ("route".equals(kind)) {
+ sb.append(hit.getString("route")).append(" (from
").append(hit.getString(field)).append(')');
+ } else {
+ sb.append(hit.getString(field)).append(" in route
").append(hit.getString("route"));
+ if (hit.getString("step") != null && !"step".equals(kind)) {
+ sb.append(" (step
").append(hit.getString("step")).append(')');
+ }
+ }
+ }
+ }
+
@Override
protected String describe(JsonObject answer) {
List<Object> ids = answer.getCollection("ids");
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 6aaf456178f5..8d402a6bb73c 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
@@ -41,6 +41,7 @@ public final class ToolGroups {
public static final String CHAOS_LIST_TOOL = "camel_runtime_chaos_list";
public static final String CHAOS_REPORT_TOOL =
"camel_runtime_chaos_report";
public static final String CHAOS_ADD_TOOL = "camel_runtime_chaos_add";
+ public static final String CHAOS_CHECK_TOOL = "camel_runtime_chaos_check";
public static final String CHAOS_CHANGE_TOOL =
"camel_runtime_chaos_change";
public static final String CHAOS_REMOVE_TOOL =
"camel_runtime_chaos_remove";
public static final String CHAOS_CLEAR_TOOL = "camel_runtime_chaos_clear";
@@ -138,8 +139,8 @@ public final class ToolGroups {
case CHAOS -> groups.add(new Group(
group,
f.chaosOn()
- ? List.of(CHAOS_LIST_TOOL, CHAOS_REPORT_TOOL,
CHAOS_ADD_TOOL, CHAOS_CHANGE_TOOL,
- CHAOS_REMOVE_TOOL, CHAOS_CLEAR_TOOL)
+ ? List.of(CHAOS_LIST_TOOL, CHAOS_REPORT_TOOL,
CHAOS_CHECK_TOOL, CHAOS_ADD_TOOL,
+ CHAOS_CHANGE_TOOL, CHAOS_REMOVE_TOOL,
CHAOS_CLEAR_TOOL)
: List.of(CHAOS_LIST_TOOL, CHAOS_REPORT_TOOL),
describeChaos(f)));
}
@@ -236,7 +237,8 @@ public final class ToolGroups {
}
return "Chaos: camel-chaos is on, so failures may be injected on
purpose; " + CHAOS_LIST_TOOL + " and "
+ 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.";
+ + " 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.";
}
/** {@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 35d7936cbeaa..fceacc0efc98 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
@@ -81,7 +81,10 @@ public final class ToolRegistry {
* over HTTP chaos only lists and reports, as its developer console does
(design/chaos.adoc).
*/
public static List<ToolDescriptor> httpTools() {
- return TOOLS.stream().filter(t -> !t.name().startsWith("chaos_") ||
t.isReadOnly()).toList();
+ // chaos_check is read-only, but the integration parses the rule, and
exception= loads a class: off HTTP too
+ return TOOLS.stream()
+ .filter(t -> !t.name().startsWith("chaos_") || t.isReadOnly()
&& !"chaos_check".equals(t.name()))
+ .toList();
}
public static ToolDescriptor findTool(String name) {
@@ -731,12 +734,12 @@ public final class ToolRegistry {
+ " with chaos switched on)";
/**
- * The chaos tools, the six operations of camel cmd chaos-add,
chaos-change, chaos-remove, chaos-clear and camel get
- * chaos [--report]. The list and the report read the chaos console in the
status of the integration; the others
- * send the chaos action of the CLI connector, which works only when chaos
is switched on and answers a rule that is
- * not valid with the reason of the parser of camel.chaos.rules. Adding
and changing a rule is destructive: a rule
- * makes messages fail, wait or get lost, and exception= can name any
exception class. Removing and clearing only
- * take faults away, and the report keeps the removed rules.
+ * The chaos tools, the seven operations of camel cmd chaos-add
[--dry-run], chaos-change, chaos-remove, chaos-clear
+ * and camel get chaos [--report]. The list and the report read the chaos
console in the status of the integration;
+ * the others send the chaos action of the CLI connector, which works only
when chaos is switched on and answers a
+ * rule that is not valid with the reason of the parser of
camel.chaos.rules. Adding and changing a rule is
+ * destructive: a rule makes messages fail, wait or get lost, and
exception= can name any exception class. Removing
+ * and clearing only take faults away, and the report keeps the removed
rules.
*/
private static void registerChaosTools() {
register(tool("chaos_list",
@@ -783,6 +786,22 @@ public final class ToolRegistry {
return chaos(ctx, "add", null, rule);
}));
+ register(tool("chaos_check",
+ "Check chaos rules without adding them (as camel cmd chaos-add
--dry-run): each rule in plain words, "
+ + "where it can fire, warnings, and what
it would hit now (hits: the sends, polls, "
+ + "routes or steps, each with its route).
A rule that is not valid is refused "
+ + "with the reason, as chaos_add would;
nothing changes.")
+ .param("rule", "string",
+ "The rule in its one-line text form, as an entry of
camel.chaos.rules; several rules separated by ;",
+ true)
+ .param("name", "string", CHAOS_NAME, false)
+ .readOnly(true).destructive(false)
+ .executor((ctx, args) -> {
+ String rule = AuthoringTools.required(args, "rule");
+ selectChaosProcess(ctx, args);
+ return chaos(ctx, "check", null, rule);
+ }));
+
register(tool("chaos_change",
"Change a chaos rule of the integration: the rule with the id
gets the new one-line rule and one more "
+ "version; the report keeps the version
it replaces. Returns the id, the new "
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 617516b29b66..7c06a083e250 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
@@ -232,7 +232,7 @@ class AskToolsTest {
assertThat(change.title()).isEqualTo("change chaos rule r1 of the
selected integration");
assertThat(change.target()).isEqualTo("endpoint=* drop");
// the others only read or take faults away
- for (String name : List.of("chaos_remove", "chaos_clear",
"chaos_list", "chaos_report")) {
+ for (String name : List.of("chaos_check", "chaos_remove",
"chaos_clear", "chaos_list", "chaos_report")) {
assertThat(tools.confirmation(name, Map.of("id",
"r1"))).as(name).isNull();
}
}
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 7baf786508ed..e0477eca66e2 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
@@ -122,6 +122,42 @@ class CamelChaosActionTest extends
ActionCommandTestSupport {
"Added chaos rule r1: fail half of the sends to http:stock*
with HTTP status 503, for 5 minutes");
}
+ @Test
+ void dryRunChecksTheRuleAndPrintsWhatItWouldHit() throws Exception {
+ writeStatusFile(TEST_PID, "orders");
+ JsonObject hits = new JsonObject(
+ Map.of(
+ "sends", new JsonArray(
+ List.of(new JsonObject(
+ Map.of("route", "orders", "endpoint",
"http://stock", "step", "callStock")))),
+ "dynamicSends", new JsonArray(
+ List.of(new JsonObject(
+ Map.of("route", "orders", "uri",
"http:stock/${header.id}"))))));
+ JsonObject rule = new JsonObject(
+ Map.of("rule", "endpoint=http:stock* error status=503",
+ "description", "fail every send to http:stock* with
HTTP status 503", "hits", hits));
+ JsonObject nothing = new JsonObject(
+ Map.of("rule", "step=tpyo error",
+ "description", "fail every run of step tpyo", "hits",
new JsonObject(),
+ "warnings", new JsonArray(List.of("step=tpyo matches
no step of the context now"))));
+ JsonObject answer = new JsonObject(Map.of("rules", new
JsonArray(List.of(rule, nothing))));
+
+ assertThat(callWithResponse(command(new CamelChaosAddAction(main()),
"orders", "--dry-run",
+ "--rule=endpoint=http:stock* error status=503",
"--rule=step=tpyo error"), answer)).isZero();
+
+
assertThat(readActionFile(TEST_PID).getString("command")).isEqualTo("check");
+ assertThat(printer.getStdout()).isEqualTo("""
+ Would add: endpoint=http:stock* error status=503
+ fail every send to http:stock* with HTTP status 503
+ send http://stock in route orders (step callStock)
+ send (known when it sends) http:stock/${header.id} in route
orders
+ Would add: step=tpyo error
+ fail every run of step tpyo
+ hits nothing in the integration now
+ Warning: step=tpyo matches no step of the context now
+ (dry run: nothing was added)""");
+ }
+
@Test
void addOfSeveralRulesPrintsEachInPlainWords() throws Exception {
writeStatusFile(TEST_PID, "orders");
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 f2830b7b4d4a..da9c37b4302d 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,15 +144,17 @@ class ToolGroupsTest {
}
@Test
- void chaosLoadsItsSixToolsWhenOnAndOnlyTheReadsWhenOff() {
+ void chaosLoadsItsSevenToolsWhenOnAndOnlyTheReadsWhenOff() {
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_add",
- "camel_runtime_chaos_change", "camel_runtime_chaos_remove",
"camel_runtime_chaos_clear"), s.mcpTools());
+ assertEquals(List.of("camel_runtime_chaos_list",
"camel_runtime_chaos_report", "camel_runtime_chaos_check",
+ "camel_runtime_chaos_add", "camel_runtime_chaos_change",
"camel_runtime_chaos_remove",
+ "camel_runtime_chaos_clear"), 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.",
+ + " such as endpoint=kafka:orders* error rate=0.1
until=5m, and camel_runtime_chaos_check says what it"
+ + " would hit without adding it.",
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 10d7185c5b9e..7db1a1e6744d 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
@@ -316,7 +316,7 @@ class ToolRegistryTest {
@Test
void theChaosToolsHaveTheMarksOfTheMcpTools() {
// the same marks as camel_runtime_chaos_* (design/chaos.adoc,
"Control")
- for (String name : List.of("chaos_list", "chaos_report")) {
+ for (String name : List.of("chaos_list", "chaos_report",
"chaos_check")) {
ToolDescriptor t = ToolRegistry.findTool(name);
assertTrue(t.isReadOnly(), name);
assertFalse(t.isDestructive(), name);
@@ -382,8 +382,9 @@ class ToolRegistryTest {
List<String> http =
ToolRegistry.httpTools().stream().map(ToolDescriptor::name).toList();
List<String> all =
ToolRegistry.allTools().stream().map(ToolDescriptor::name).toList();
List<String> left = all.stream().filter(n ->
!http.contains(n)).toList();
- // only the chaos tools that change the rules stay off HTTP; the other
tools keep what they offered
- assertEquals(List.of("chaos_add", "chaos_change", "chaos_remove",
"chaos_clear"), left);
+ // only the chaos tools that change the rules, and the check (the
integration parses the rule), stay off HTTP;
+ // the other tools keep what they offered
+ assertEquals(List.of("chaos_add", "chaos_check", "chaos_change",
"chaos_remove", "chaos_clear"), left);
assertTrue(http.containsAll(List.of("chaos_list", "chaos_report",
"stop_route", "execute_sql")));
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/JbangDevMcpServerTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/JbangDevMcpServerTest.java
index 99e8e6590248..98d0e3e8c750 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/JbangDevMcpServerTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/JbangDevMcpServerTest.java
@@ -77,7 +77,7 @@ class JbangDevMcpServerTest {
.contains(ToolRegistry.allTools().get(0).name())
// over HTTP chaos only lists and reports: a rule is never
added, changed or removed over HTTP
.contains("chaos_list", "chaos_report")
- .doesNotContain("chaos_add", "chaos_change",
"chaos_remove", "chaos_clear");
+ .doesNotContain("chaos_check", "chaos_add",
"chaos_change", "chaos_remove", "chaos_clear");
McpSchema.Tool parameterizedTool =
client.listTools().tools().stream()
.filter(t -> "select_process".equals(t.name()))
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 2db0b0231723..5816e13eb9ad 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
@@ -640,8 +640,8 @@ Examples:
camel cmd browse --endpoint=seda:foo --limit=10
== camel cmd chaos-add --help
-Usage: camel cmd chaos-add [-h] [--timeout=<timeout>] --rule=<rules>
- [--rule=<rules>]... [<name>]
+Usage: camel cmd chaos-add [-h] [--dry-run] [--timeout=<timeout>]
+ --rule=<rules> [--rule=<rules>]... [<name>]
Add chaos rules to a running integration (camel-chaos, for development only)
[<name>] Name or pid of the running Camel integration (must
match one); without it, the only one running, or
@@ -652,6 +652,10 @@ Add chaos rules to a running integration (camel-chaos, for
development only)
Default: 10000
--rule=<rules> A rule in its one-line text form, as an entry of
camel.chaos.rules; can be given more than once
+ --dry-run Check the rules without adding them: each in plain
+ words, and what it would hit in the integration
+ now (the sends, polls, routes or steps, with
+ their route)
The integration must have camel-chaos switched on (camel run --chaos, profile
dev). The rule is checked as camel.chaos.rules is; a rule that is not valid is
@@ -679,6 +683,8 @@ rate=0.1'
country} == \"ES\""'
camel cmd chaos-add myApp --rule='endpoint=seda:dlq error at=dlq'
--rule='endpoint=http:stock* error'
+ camel cmd chaos-add myApp --dry-run --rule='endpoint=http:* in=orders error
+status=503'
== camel cmd chaos-change --help
Usage: camel cmd chaos-change [-h] --id=<id> --rule=<rule>
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 7fd1123eabf8..a4553d8ac4df 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
@@ -1136,6 +1136,20 @@ public class RuntimeTools {
return delegateChaos("chaos_report", nameOrPid, Map.of());
}
+ @ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
+ description = """
+ Check chaos rules without adding them (as camel cmd
chaos-add --dry-run): each rule in plain words, \
+ where it can fire, warnings, and what it would hit now
(hits: the sends, polls, routes or steps, \
+ each with its route). A rule that is not valid is refused
with the reason; nothing changes.""")
+ public JsonObject camel_runtime_chaos_check(
+ @ToolArg(description = CHAOS_NAME_OR_PID_DESC, required = false)
String nameOrPid,
+ @ToolArg(description = "The rule in its one-line text form, as an
entry of camel.chaos.rules; several"
+ + " rules separated by ;") String rule) {
+ requireArg("rule", rule);
+ return delegateChaos("chaos_check", nameOrPid, Map.of("rule", rule));
+ }
+
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
@Tool(annotations = @Tool.Annotations(readOnlyHint = false,
destructiveHint = true, openWorldHint = false),
description = """
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 faa54e1810f3..b240abd3f67b 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
@@ -45,6 +45,7 @@ class RuntimeToolsChaosSecurityTest {
static {
MARKS.put("camel_runtime_chaos_list", new boolean[] { true, false });
MARKS.put("camel_runtime_chaos_report", new boolean[] { true, false });
+ MARKS.put("camel_runtime_chaos_check", new boolean[] { true, false });
MARKS.put("camel_runtime_chaos_add", new boolean[] { false, true });
MARKS.put("camel_runtime_chaos_change", new boolean[] { false, true });
MARKS.put("camel_runtime_chaos_remove", new boolean[] { false, false
});
@@ -98,11 +99,11 @@ class RuntimeToolsChaosSecurityTest {
@Test
void theAccessLevelsOfferTheToolsByTheirMarks() {
assertThat(offered(McpSecurityConfig.AccessLevel.READ_ONLY))
- .containsExactly("camel_runtime_chaos_list",
"camel_runtime_chaos_report");
+ .containsExactly("camel_runtime_chaos_list",
"camel_runtime_chaos_report", "camel_runtime_chaos_check");
// as for route_control, sql, send and receive: no destructive tool at
read-write
assertThat(offered(McpSecurityConfig.AccessLevel.READ_WRITE)).containsExactly(
- "camel_runtime_chaos_list", "camel_runtime_chaos_report",
"camel_runtime_chaos_remove",
- "camel_runtime_chaos_clear");
+ "camel_runtime_chaos_list", "camel_runtime_chaos_report",
"camel_runtime_chaos_check",
+ "camel_runtime_chaos_remove", "camel_runtime_chaos_clear");
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)
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosTest.java
index 3ab4cb22ebf8..913206909627 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsChaosTest.java
@@ -162,6 +162,13 @@ class RuntimeToolsChaosTest {
start(null, Map.of("camel.chaos.enabled", "true"));
assertThat(awaitList(l -> l.getBoolean("enabled")).<List<Object>>
getCollection("rules")).isEmpty();
+ // the check says what the rule would hit, and adds nothing
+ JsonObject checked = tools.camel_runtime_chaos_check("shop",
RULE_ERROR);
+ Map<String, Object> rule = checked.<List<Map<String, Object>>>
getCollection("rules").get(0);
+ assertThat(rule.get("description")).isEqualTo("fail every send to
direct:target");
+
assertThat(rule.get("hits").toString()).contains("direct://target").contains("start");
+ assertThat(send()).isNull();
+
JsonObject added = tools.camel_runtime_chaos_add("shop", RULE_ERROR);
assertThat(added.getString("id")).isEqualTo("r1");
assertThat(added.getInteger("version")).isEqualTo(1);
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
index ce032ccd2524..4283db6239ce 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
@@ -125,7 +125,7 @@ class RuntimeToolsTest {
"get_sql_trace",
"get_circuit_breakers", "get_metrics",
"get_eip_stats", "get_spans", "get_startup_steps",
"get_route_analysis", "detect_config_drift",
- "chaos_list", "chaos_report", "chaos_add", "chaos_change",
"chaos_remove", "chaos_clear")) {
+ "chaos_list", "chaos_report", "chaos_check", "chaos_add",
"chaos_change", "chaos_remove", "chaos_clear")) {
assertThat(ToolRegistry.findTool(name)).as(name).isNotNull();
}
}