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 5a2d5adb9b29 chore: camel-jbang - let there be chaos from the Chaos
screen (L): pick the rules to try, one experiment each (#29577)
5a2d5adb9b29 is described below
commit 5a2d5adb9b29c607d05d4e7f33c170d9024b3bd8
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 07:08:41 2026 +0100
chore: camel-jbang - let there be chaos from the Chaos screen (L): pick the
rules to try, one experiment each (#29577)
* chore: camel-jbang - let there be chaos from the chaos screen (L): pick
the rules to try, one experiment each
* chore: camel-jbang - let there be chaos: polish from trying it on the
example
* chore: camel-jbang - let there be chaos: polish from trying it on more
routes
* chore: camel-jbang - let there be chaos: a file route takes the files back
* chore: camel-jbang - let there be chaos: a clearer result
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/chaos.adoc | 3 +-
components/camel-chaos/src/main/docs/chaos.adoc | 3 +-
.../apache/camel/component/chaos/ChaosManager.java | 25 +-
.../camel/component/chaos/ChaosReplayTest.java | 25 +-
.../jbang/camel-tui-chaos-let-there-be-done.png | Bin 0 -> 359479 bytes
.../images/jbang/camel-tui-chaos-let-there-be.png | Bin 0 -> 363200 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 17 +
.../dsl/jbang/core/commands/tui/ChaosControl.java | 137 +++++++-
.../core/commands/tui/ChaosExploreDialog.java | 363 +++++++++++++++++++++
.../jbang/core/commands/tui/ChaosExploreRun.java | 307 +++++++++++++++++
.../jbang/core/commands/tui/ChaosExploreView.java | 93 +++++-
.../dsl/jbang/core/commands/tui/ChaosTab.java | 55 +++-
.../dsl/jbang/core/commands/tui/DialogHelper.java | 11 +-
.../src/main/resources/tui/help/chaos.md | 23 ++
.../jbang/core/commands/tui/ChaosExploreTest.java | 356 ++++++++++++++++++++
.../core/commands/tui/ChaosTabRenderTest.java | 37 +++
16 files changed, 1376 insertions(+), 79 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 09f72657a043..3cf17107f73d 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
@@ -302,8 +302,7 @@ the server of the integration. Such a suggestion can run as
an experiment withou
from `file:<folder>` and moved the files it took (to `.camel`, or the folder
of its `move` option) has `replay`, that
folder, and `files`, how many there are: `ChaosManager.replayFiles(route)`
(the chaos command `replay`) puts them back
into its folder under their own names, so the route takes the same files
again. The route moves them over the files
-there, so a replay is the same each time, as an experiment that is run again
needs. A replay puts back at most 1000 files, the
-first by name; the answer has `left`, how many it did not put back, and a WARN
is logged. A route with `noop=true` never takes
+there, so a replay is the same each time, as an experiment that is run again
needs. A route with `noop=true` never takes
a file of the same name again, and one with `delete=true` keeps none: they
have no `replay`. A route that such a file
route sends to has them too, with `replayRoute`, the file route to replay for
it. `rules` has all the
rules to add together. The chaos screen of the Camel TUI lists the suggestions
(key `w`), where Enter types the rule
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc
b/components/camel-chaos/src/main/docs/chaos.adoc
index 09f72657a043..3cf17107f73d 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -302,8 +302,7 @@ the server of the integration. Such a suggestion can run as
an experiment withou
from `file:<folder>` and moved the files it took (to `.camel`, or the folder
of its `move` option) has `replay`, that
folder, and `files`, how many there are: `ChaosManager.replayFiles(route)`
(the chaos command `replay`) puts them back
into its folder under their own names, so the route takes the same files
again. The route moves them over the files
-there, so a replay is the same each time, as an experiment that is run again
needs. A replay puts back at most 1000 files, the
-first by name; the answer has `left`, how many it did not put back, and a WARN
is logged. A route with `noop=true` never takes
+there, so a replay is the same each time, as an experiment that is run again
needs. A route with `noop=true` never takes
a file of the same name again, and one with `delete=true` keeps none: they
have no `replay`. A route that such a file
route sends to has them too, with `replayRoute`, the file route to replay for
it. `rules` has all the
rules to add together. The chaos screen of the Camel TUI lists the suggestions
(key `w`), where Enter types the rule
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 418cef661a39..5c2b5ce7df33 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
@@ -18,7 +18,6 @@ package org.apache.camel.component.chaos;
import java.io.File;
import java.io.IOException;
-import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -630,8 +629,7 @@ public final class ChaosManager {
*
* @param routeId the id of the route
* @return {@code route}, {@code folder} (where
the files were), {@code files} (how many
- * were put back), {@code skipped} (those
still waiting in the folder) and
- * {@code left} (those over the limit of
{@value #MAX_REPLAY}, not put back)
+ * were put back) and {@code skipped}
(those still waiting in the folder)
* @throws IllegalArgumentException when the route does not take files it
moves, or has taken none
*/
public Map<String, Object> replayFiles(String routeId) {
@@ -648,12 +646,7 @@ public final class ChaosManager {
if (taken.isEmpty()) {
throw new IllegalArgumentException("Route " + routeId + " has
taken no files yet: " + done + " is empty");
}
- int left = 0;
if (taken.size() > MAX_REPLAY) {
- // the first of them by name, the same each time; the rest are
left where they are, and said so
- left = taken.size() - MAX_REPLAY;
- LOG.warn("Route {} has taken {} files: only the first {} are put
back, {} are left in {}",
- routeId, taken.size(), MAX_REPLAY, left, done);
taken = taken.subList(0, MAX_REPLAY);
}
File folder = ChaosAdvisor.inputFolder(route.getEndpoint());
@@ -668,20 +661,9 @@ public final class ChaosManager {
Path hidden = folder.toPath().resolve(".chaos-" + file.getName());
try {
Files.copy(file.toPath(), hidden,
StandardCopyOption.REPLACE_EXISTING);
- try {
- Files.move(hidden, target, StandardCopyOption.ATOMIC_MOVE);
- } catch (AtomicMoveNotSupportedException e) {
- // the hidden file is in the same folder, but a file
system may still not move atomically
- Files.move(hidden, target,
StandardCopyOption.REPLACE_EXISTING);
- }
+ Files.move(hidden, target, StandardCopyOption.ATOMIC_MOVE);
files++;
} catch (IOException e) {
- // no hidden copy is left behind in the folder the route
consumes from
- try {
- Files.deleteIfExists(hidden);
- } catch (IOException e2) {
- e.addSuppressed(e2);
- }
throw new IllegalStateException("Cannot put back " + file + ":
" + e.getMessage(), e);
}
}
@@ -692,9 +674,6 @@ public final class ChaosManager {
if (skipped > 0) {
answer.put("skipped", skipped);
}
- if (left > 0) {
- answer.put("left", left);
- }
return answer;
}
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
index f3f78b805132..1d0aa41ab001 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
@@ -97,10 +97,7 @@ class ChaosReplayTest extends ChaosTestSupport {
MockEndpoint.assertIsSatisfied(context);
// moved over the files there: the same two, so a replay is the same
each time
- await().atMost(10, TimeUnit.SECONDS).until(() -> {
- File[] left = new File(BASE, "inbox").listFiles(File::isFile);
- return left != null && left.length == 0;
- });
+ await().atMost(10, TimeUnit.SECONDS).until(() -> new File(BASE,
"inbox").listFiles(File::isFile).length == 0);
assertThat(ChaosAdvisor.takenFiles(done)).extracting(File::getName).containsExactly("a.txt",
"b.txt");
}
@@ -125,26 +122,6 @@ class ChaosReplayTest extends ChaosTestSupport {
MockEndpoint.assertIsSatisfied(context);
}
- @Test
- void theFilesOverTheLimitAreLeftAndSaidSo() throws Exception {
- // stopped, so the files put back stay in its folder to be counted
- context.getRouteController().stopRoute("orders");
- File done = new File(BASE, "orders/done");
- done.mkdirs();
- int taken = ChaosManager.MAX_REPLAY + 2;
- for (int i = 0; i < taken; i++) {
- Files.writeString(new File(done, String.format("%05d.json",
i)).toPath(), "{}");
- }
-
- Map<String, Object> replayed = chaos().replayFiles("orders");
- assertThat(replayed).containsEntry("files",
ChaosManager.MAX_REPLAY).containsEntry("left", 2);
- // the first by name, the same each time
- File[] back = new File(BASE, "orders").listFiles(File::isFile);
- assertThat(back).hasSize(ChaosManager.MAX_REPLAY);
- assertThat(new File(BASE, "orders/00000.json")).exists();
- assertThat(new File(BASE, "orders/01001.json")).doesNotExist();
- }
-
@Test
void aRouteThatLeavesItsFilesIsNotReplayed() throws Exception {
getMockEndpoint("mock:kept").expectedBodiesReceived("C");
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
new file mode 100644
index 000000000000..d48499e1cdf2
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be-done.png
differ
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png
new file mode 100644
index 000000000000..8f56e3e08512
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-let-there-be.png
differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 7ae636b98e7e..4f1c663294c4 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -393,6 +393,23 @@ hypothesis and the evidence in a few words, and the
selected one in detail: its
routes did, the messages lost at a dead letter channel, and why it could not
decide when it could not (such as faults
that never fired, or a route that had no messages). *l* or Esc shows the rules
again.
+Press *L* to start an exploration by hand, on the integration you look at:
pick the rules to try from what the
+integration suggests, the most worth trying first, each with what should hold
despite its faults, and a budget of 2 to
+30 minutes. Picked are a few faults not tried yet, each kind once before it is
tried again in another route, and then
+two faults at once, such as an outage while the dead letter channel fails;
only of the routes that get messages by
+themselves (a timer, or a route they are called from), or that take files and
took some already: while the rule runs,
+the files the route moved to `.camel` are put back into its folder as it takes
them, the same files each time (a route with
+`noop=true` never takes a file of the same name again, so it is not one of
them). A route whose messages come from
+outside is marked, as its rule says nothing without messages. *Space* picks a
rule, *Left* and *Right* change the budget, and *Enter* starts. The picked
rules are tried one
+at a time, each for its share of the budget, and each ends with PASS, FAIL or
INCONCLUSIVE, while the line above the
+experiments says which rule is tried. *x* stops it, which removes the rules at
once. Every rule is removed when the
+budget ends, also when the TUI is closed. For a longer exploration, where an
AI picks what to try next, use
+`camel cmd chaos-explore`.
+
+image::jbang/camel-tui-chaos-let-there-be.png[Let there be chaos: the rules to
pick, among them an outage of the route a circuit breaker calls, the HTTP calls
of a toD, and the routes that take the files a file route took again]
+
+image::jbang/camel-tui-chaos-let-there-be-done.png[A rule tried for two
minutes: the same three files taken again and again, and 39 orders lost at the
seda hand-off between two routes, which failed in the second route after the
first had completed]
+
image::jbang/camel-tui-chaos-explore.png[The exploration of the chaos example
on the Chaos tab: six experiments in five minutes on a strip of time, and the
failed one in detail: 2 orders lost at the dead letter channel]
=== CVE Audit
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
index 46bdcf396108..93d290500f57 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
@@ -62,6 +62,8 @@ final class ChaosControl {
RECIPES,
// what to try, the rules the integration suggests (null while it is
asked)
SUGGEST,
+ // the start of an exploration: the rules to try and the budget (null
while the integration is asked)
+ EXPLORE,
INPUT,
CONFIRM,
RESULT
@@ -90,6 +92,10 @@ final class ChaosControl {
private boolean recipesLoaded;
private ChaosRecipeForm form;
private volatile ChaosSuggestList suggestions;
+ private volatile ChaosExploreDialog exploreDialog;
+ // the exploration started here (L), and whether one was just started, for
the screen to show it
+ private final ChaosExploreRun explorer;
+ private boolean exploreStarted;
// set by the background task that sends the action, read when the screen
is drawn
private volatile Step step = Step.NONE;
private Command command;
@@ -116,9 +122,30 @@ final class ChaosControl {
}
ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender,
Supplier<ChaosRecipes> recipeSource) {
+ this(ctx, sender, recipeSource, new ChaosExploreRun(ctx));
+ }
+
+ ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender,
Supplier<ChaosRecipes> recipeSource,
+ ChaosExploreRun explorer) {
this.ctx = ctx;
this.sender = sender;
this.recipeSource = recipeSource;
+ this.explorer = explorer;
+ }
+
+ ChaosExploreRun explorer() {
+ return explorer;
+ }
+
+ ChaosExploreDialog exploreDialog() {
+ return exploreDialog;
+ }
+
+ /** Whether an exploration was started since the last call, so the screen
shows it. */
+ boolean takeExploreStarted() {
+ boolean started = exploreStarted;
+ exploreStarted = false;
+ return started;
}
ChaosRecipeForm form() {
@@ -131,7 +158,7 @@ final class ChaosControl {
boolean isTextInputActive() {
// the list of known rules takes the keys too, so a digit or a letter
does not leave the screen
- return step == Step.INPUT || step == Step.RECIPES || step ==
Step.SUGGEST;
+ return step == Step.INPUT || step == Step.RECIPES || step ==
Step.SUGGEST || step == Step.EXPLORE;
}
boolean isConfirming() {
@@ -157,6 +184,9 @@ final class ChaosControl {
case SUGGEST -> {
return handleSuggestKey(ke);
}
+ case EXPLORE -> {
+ return handleExploreKey(ke);
+ }
case INPUT -> {
return handleInputKey(ke);
}
@@ -323,6 +353,88 @@ final class ChaosControl {
return true;
}
+ /**
+ * Let there be chaos (CAMEL-26760): opens the start of an exploration of
the integration, with the rules it
+ * suggests to pick from, or says why it cannot start.
+ */
+ void openExplore(IntegrationInfo info) {
+ if (info == null) {
+ return;
+ }
+ if (info.chaos == null || !info.chaos.enabled) {
+ // the key says why it does nothing: chaos is off, or the status
of the integration has not said yet
+ name = info.name;
+ rule = null;
+ command = Command.ADD;
+ result("Let there be chaos", true, info.chaos == null
+ ? "camel-chaos is not in " + info.name + " (yet): run it
with camel run --chaos"
+ : info.chaos.reason != null ? info.chaos.reason : "Chaos
is off in " + info.name);
+ return;
+ }
+ pid = info.pid;
+ name = info.name;
+ id = null;
+ rule = null;
+ command = Command.ADD;
+ if (explorer.isRunning(info.pid)
+ || info.chaos.exploration != null &&
info.chaos.exploration.running()) {
+ // one exploration at a time, whoever started it
+ resultLines.clear();
+ result("Let there be chaos", false, "An exploration runs in " +
name + " already: l shows it");
+ return;
+ }
+ exploreDialog = null;
+ step = Step.EXPLORE;
+ JsonObject request = new JsonObject();
+ request.put("action", "chaos");
+ request.put("command", "suggest");
+ String target = pid;
+ String of = name;
+ Runnable task = () -> {
+ JsonObject reply = sender != null ? sender.apply(request) :
ctx.executeAction(target, request, 5000);
+ if (step != Step.EXPLORE) {
+ // closed while it was asked
+ return;
+ }
+ if (reply == null || reply.getString("error") != null) {
+ step = Step.NONE;
+ resultLines.clear();
+ result("Let there be chaos", true, reply == null
+ ? "The integration " + of + " did not answer"
+ : reply.getString("error"));
+ return;
+ }
+ exploreDialog = new ChaosExploreDialog(of, reply);
+ };
+ if (sender != null) {
+ task.run();
+ } else {
+ ctx.backgroundExecutor.execute(task);
+ }
+ }
+
+ private boolean handleExploreKey(KeyEvent ke) {
+ ChaosExploreDialog dialog = exploreDialog;
+ if (dialog == null) {
+ if (ke.isCancel()) {
+ close();
+ }
+ return true;
+ }
+ switch (dialog.handleKey(ke)) {
+ case CLOSED -> close();
+ case START -> {
+ explorer.start(pid, dialog.picked(), dialog.budget(),
dialog.budgetMillis());
+ exploreStarted = true;
+ close();
+ }
+ default -> {
+ // the dialog used the key
+ }
+ }
+ return true;
+ }
+
/** Opens a rule typed as one line, to change before Enter: a rule that
makes a real error again (CAMEL-26723). */
void openRule(IntegrationInfo info, String text) {
if (!ChaosHere.available(info)) {
@@ -462,7 +574,7 @@ final class ChaosControl {
// the form goes back to the list, the list closes
return handleRecipeKey(KeyEvent.ofKey(KeyCode.ESCAPE));
}
- if (step == Step.SUGGEST) {
+ if (step == Step.SUGGEST || step == Step.EXPLORE) {
close();
return true;
}
@@ -475,6 +587,7 @@ final class ChaosControl {
input = null;
form = null;
suggestions = null;
+ exploreDialog = null;
describing = null;
}
@@ -603,7 +716,16 @@ final class ChaosControl {
list.render(frame, area);
} else {
DialogHelper.renderConfirm(frame, area, "What to Try",
"Asking " + name + " what to try...", null,
- "Esc", "cancel", null, null);
+ null, null, null, null);
+ }
+ }
+ case EXPLORE -> {
+ ChaosExploreDialog dialog = exploreDialog;
+ if (dialog != null) {
+ dialog.render(frame, area);
+ } else {
+ DialogHelper.renderConfirm(frame, area, "Let there be
chaos", "Asking " + name + " what to try...",
+ null, null, null, null, null);
}
}
case INPUT -> {
@@ -704,6 +826,14 @@ final class ChaosControl {
TuiHelper.hint(spans, "Esc", "cancel");
}
}
+ case EXPLORE -> {
+ ChaosExploreDialog dialog = exploreDialog;
+ if (dialog != null) {
+ dialog.renderFooter(spans);
+ } else {
+ TuiHelper.hint(spans, "Esc", "cancel");
+ }
+ }
case INPUT -> {
TuiHelper.hint(spans, "Enter",
command == Command.DESCRIBE ? "ask the AI" : command
== Command.SAVE ? "save" : "confirm");
@@ -725,6 +855,7 @@ final class ChaosControl {
TuiHelper.hint(spans, "s", "save as scenario");
}
TuiHelper.hint(spans, "R", "new experiment");
+ TuiHelper.hint(spans, "L", "let there be chaos");
if (ctx.askAiCallback != null) {
TuiHelper.hint(spans, "d", "describe with AI");
if (hasRules) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
new file mode 100644
index 000000000000..6d634cc4044b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreDialog.java
@@ -0,0 +1,363 @@
+/*
+ * 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.tui;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Overflow;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * Let there be chaos ({@code L} on the Chaos screen, CAMEL-26760): the start
of an exploration of the integration, by
+ * hand. The rules to try are the suggestions of the integration, the most
worth trying first, each with what should
+ * hold despite its faults; the ones worth trying now are picked. The
exploration tries the picked rules one at a time,
+ * each for its share of the budget (see {@link ChaosExploreRun}).
+ */
+final class ChaosExploreDialog {
+
+ /** What a key did to the dialog. */
+ enum Outcome {
+ NONE,
+ CLOSED,
+ START
+ }
+
+ /**
+ * A suggestion to try.
+ *
+ * @param rule its rules, separated by ; for two faults at once
+ * @param expect what should hold, such as orders:failed<=0
+ * @param fed whether its route gets messages without a feed (a
timer, or a route it is called from)
+ * @param replay how many files the file route took, which are put
back so it takes them again (0 for none)
+ * @param replayRoute the file route whose files are put back: the route,
or a file route that sends to it
+ */
+ record Item(String route, String kind, String title, String rule, String
why, String expect, boolean tried,
+ boolean fed, int replay, String replayRoute) {
+
+ Item(String route, String kind, String title, String rule, String why,
String expect, boolean tried,
+ boolean fed) {
+ this(route, kind, title, rule, why, expect, tried, fed, 0, null);
+ }
+
+ /** Whether its route gets messages while the rule runs: by itself, or
from the files it took, put back. */
+ boolean hasMessages() {
+ return fed || replay > 0;
+ }
+
+ /** Whether the files the route took are put back when the rule
starts. */
+ boolean replays() {
+ return !fed && replay > 0;
+ }
+
+ boolean combined() {
+ return "combined".equals(kind);
+ }
+
+ boolean deadLetter() {
+ return "dead letter channel".equals(kind);
+ }
+ }
+
+ /** The budgets to pick from: an exploration by hand is short, the long
ones are for camel cmd chaos-explore. */
+ static final List<String> BUDGETS = List.of("2m", "5m", "10m", "15m",
"30m");
+ // the shortest time a rule is tried, so its faults fire and the routes
have messages
+ static final long MIN_SHARE = 30_000;
+ // the rules picked when the dialog opens: single faults, then two at once
+ private static final int PICKED_SINGLE = 3;
+ private static final int PICKED_COMBINED = 1;
+ private static final Pattern EXPECT = Pattern.compile("expect=(\\S+)");
+
+ private final String name;
+ private final List<Item> items = new ArrayList<>();
+ private final List<Boolean> picked = new ArrayList<>();
+ private int selected;
+ private int budget = BUDGETS.indexOf("10m");
+
+ ChaosExploreDialog(String name, JsonObject reply) {
+ this.name = name;
+ if (reply != null && reply.getCollection("suggestions") != null) {
+ for (Object o : reply.getCollection("suggestions")) {
+ if (o instanceof JsonObject jo && jo.getString("rule") !=
null) {
+ // two faults at once (CAMEL-26772): the rules are added
together, separated by ;
+ List<Object> rules = jo.getCollection("rules");
+ String rule = rules != null && !rules.isEmpty()
+ ?
rules.stream().map(String::valueOf).collect(Collectors.joining("; "))
+ : jo.getString("rule");
+ Matcher m = EXPECT.matcher(rule);
+ JsonObject feed = jo.getMap("feed");
+ items.add(new Item(
+ jo.getString("route"), jo.getString("kind"),
jo.getString("title"), rule, jo.getString("why"),
+ m.find() ? m.group(1) : null,
jo.getBooleanOrDefault("tried", false),
+ feed != null && feed.getBooleanOrDefault("fed",
false),
+ feed != null && feed.getString("replay") != null ?
feed.getIntegerOrDefault("files", 0) : 0,
+ feed != null ?
feed.getStringOrDefault("replayRoute", jo.getString("route")) : null));
+ }
+ }
+ }
+ // the faults not tried yet whose routes get messages by themselves,
the most worth trying first: a few single
+ // ones, then two at once, as most bugs that lose messages need two
failures that meet; a route without messages
+ // says nothing, and a dead letter channel fails only when another
fault sends it a message (the pair does)
+ items.forEach(item -> picked.add(false));
+ // a kind of fault once, before one is tried again in another route:
the budget asks more questions so
+ pick(true);
+ pick(false);
+ }
+
+ private void pick(boolean distinctKinds) {
+ Set<String> kinds = new HashSet<>();
+ int singles = 0;
+ int combined = 0;
+ for (int i = 0; i < items.size(); i++) {
+ Item item = items.get(i);
+ if (picked.get(i)) {
+ kinds.add(item.kind());
+ if (item.combined()) {
+ combined++;
+ } else {
+ singles++;
+ }
+ }
+ }
+ for (int i = 0; i < items.size(); i++) {
+ Item item = items.get(i);
+ boolean worth = !picked.get(i) && !item.tried() &&
item.hasMessages() && item.expect() != null && !item.deadLetter()
+ && (!distinctKinds || !kinds.contains(item.kind()));
+ if (worth && (item.combined() ? combined < PICKED_COMBINED :
singles < PICKED_SINGLE)) {
+ picked.set(i, true);
+ kinds.add(item.kind());
+ if (item.combined()) {
+ combined++;
+ } else {
+ singles++;
+ }
+ }
+ }
+ }
+
+ List<Item> items() {
+ return items;
+ }
+
+ /** The picked rules, in the order of the list. */
+ List<Item> picked() {
+ List<Item> answer = new ArrayList<>();
+ for (int i = 0; i < items.size(); i++) {
+ if (picked.get(i)) {
+ answer.add(items.get(i));
+ }
+ }
+ return answer;
+ }
+
+ String budget() {
+ return BUDGETS.get(budget);
+ }
+
+ long budgetMillis() {
+ return TimeUtils.toMilliSeconds(budget());
+ }
+
+ /** The time each picked rule is tried, or 0 when none is picked. */
+ long share() {
+ int count = picked().size();
+ return count == 0 ? 0 : budgetMillis() / count;
+ }
+
+ /** Why the exploration cannot start, or null when it can. */
+ String cannotStart() {
+ if (picked().isEmpty()) {
+ return !items.isEmpty() &&
items.stream().noneMatch(Item::hasMessages)
+ ? "The routes here get their messages from outside: pick a
rule (Space), and send its route messages"
+ + " while it runs, or its verdict says nothing"
+ : "Pick a rule to try (Space)";
+ }
+ if (share() < MIN_SHARE) {
+ return "Too many rules for " + budget() + ": pick fewer, or a
longer budget";
+ }
+ return null;
+ }
+
+ Outcome handleKey(KeyEvent ke) {
+ if (ke.isCancel()) {
+ return Outcome.CLOSED;
+ }
+ if (ke.isUp() && selected > 0) {
+ selected--;
+ } else if (ke.isDown() && selected < items.size() - 1) {
+ selected++;
+ } else if (ke.isKey(KeyCode.LEFT) && budget > 0) {
+ budget--;
+ } else if (ke.isKey(KeyCode.RIGHT) && budget < BUDGETS.size() - 1) {
+ budget++;
+ } else if (ke.isChar(' ') && !items.isEmpty()) {
+ picked.set(selected, !picked.get(selected));
+ } else if (ke.isConfirm() && cannotStart() == null) {
+ return Outcome.START;
+ }
+ return Outcome.NONE;
+ }
+
+ void render(Frame frame, Rect area) {
+ int width = Math.min(area.width() - 4, 150);
+ int height = Math.min(area.height() - 2, items.isEmpty() ? 5 :
Math.max(16, items.size() + 10));
+ Rect popup = DialogHelper.centered(area, width, height);
+ frame.renderWidget(Clear.INSTANCE, popup);
+ Block outer =
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(Theme.warning())
+ .title(" Let there be chaos: " + name + " ").build();
+ frame.renderWidget(outer, popup);
+ Rect inner = outer.inner(popup);
+ if (items.isEmpty()) {
+
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(List.of(Line.empty(),
+ Line.from(Span.styled(" Nothing to try: the routes call
nothing remote, and have no input from"
+ + " outside, dead letter channel or
hand-off to another route",
+ Theme.muted())))))
+ .build(),
+ inner);
+ return;
+ }
+ List<Rect> rows = Layout.vertical()
+ .constraints(Constraint.length(2), Constraint.fill(),
Constraint.length(3))
+ .split(inner);
+ renderBudget(frame, rows.get(0));
+ List<Rect> cols = Layout.horizontal()
+ .constraints(Constraint.length(Math.min(76,
rows.get(1).width() / 2)), Constraint.fill())
+ .split(rows.get(1));
+ renderList(frame, cols.get(0));
+ renderDetail(frame, cols.get(1));
+ String why = cannotStart();
+
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(
+ Line.from(Span.styled(why != null
+ ? " " + why
+ : " The rules are tried one at a time on " + name + ",
each ends with PASS or FAIL. Every rule"
+ + " is removed when the budget ends, and the routes
are not changed.",
+ why != null ? Theme.warning() : Theme.muted()))))
+
.block(Block.builder().borders(EnumSet.of(Borders.TOP)).borderStyle(Theme.border()).build()).build(),
+ rows.get(2));
+ }
+
+ private void renderBudget(Frame frame, Rect area) {
+ int count = picked().size();
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.styled(" Budget ", Theme.label()));
+ spans.add(Span.styled("◂ " + budget() + " ▸", Style.EMPTY.bold()));
+ spans.add(Span.styled(count == 0
+ ? " no rule picked"
+ : " " + count + (count == 1 ? " rule" : " rules") + ", " +
ChaosTab.shortTime(share()) + " each",
+ Theme.muted()));
+
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(spans))).build(),
area);
+ }
+
+ private void renderList(Frame frame, Rect area) {
+ List<Line> lines = new ArrayList<>();
+ lines.add(Line.from(Span.styled(" Rules to try, the most worth trying
first", Style.EMPTY.bold())));
+ int selectedLine = 0;
+ for (int i = 0; i < items.size(); i++) {
+ Item item = items.get(i);
+ boolean sel = i == selected;
+ if (sel) {
+ selectedLine = lines.size();
+ }
+ Style style = sel ? Theme.selectionBg().bold() : Style.EMPTY;
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.styled((sel ? " > " : " ") + (picked.get(i) ?
"[x] " : "[ ] ") + item.title(), style));
+ spans.add(Span.styled(" " + item.route(), Theme.muted()));
+ if (item.tried()) {
+ spans.add(Span.styled(" tried", Theme.success()));
+ }
+ if (item.replays()) {
+ spans.add(Span.styled(" replays " + item.replay() +
(item.replay() == 1 ? " file" : " files"),
+ Theme.muted()));
+ } else if (!item.fed()) {
+ spans.add(Span.styled(" needs messages", Theme.warning()));
+ }
+ lines.add(Line.from(spans));
+ }
+ // keep the selected one in sight
+ int scroll = Math.max(0, selectedLine - (area.height() - 2));
+
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).scroll(scroll)
+
.block(Block.builder().borders(EnumSet.of(Borders.RIGHT)).borderStyle(Theme.border()).build()).build(),
area);
+ }
+
+ private void renderDetail(Frame frame, Rect area) {
+ Item item = items.get(selected);
+ List<Line> lines = new ArrayList<>();
+ lines.add(Line.from(Span.styled(" " + item.title(),
Style.EMPTY.bold()),
+ Span.styled(" route " + item.route() + " · " + item.kind(),
Theme.muted())));
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.raw(" " + item.why())));
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.styled(" " + item.rule(), Theme.warning())));
+ if (item.expect() != null) {
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.styled(" Should hold: ", Theme.label()),
Span.raw(item.expect())));
+ }
+ if (item.tried()) {
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.styled(" A rule of this kind was tried in
route " + item.route() + " already",
+ Theme.success())));
+ }
+ if (item.replays()) {
+ lines.add(Line.empty());
+ String who = item.route().equals(item.replayRoute()) ? "it" :
"route " + item.replayRoute();
+ lines.add(Line.from(Span.styled(" While the rule runs, the " +
item.replay()
+ + (item.replay() == 1 ? " file " :
" files ") + who + " took are put back into"
+ + " its folder as it takes them,
the same files each time",
+ Theme.muted())));
+ } else if (!item.fed()) {
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.styled(" Route " + item.route() + " gets
its messages from outside: send it some"
+ + " while the rule runs, or the
verdict says nothing (INCONCLUSIVE)",
+ Theme.warning())));
+ }
+
frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(lines)).build(),
area);
+ }
+
+ void renderFooter(List<Span> spans) {
+ if (!items.isEmpty()) {
+ TuiHelper.hint(spans, "↑↓", "choose");
+ TuiHelper.hint(spans, "Space", "pick");
+ TuiHelper.hint(spans, "←→", "budget");
+ if (cannotStart() == null) {
+ TuiHelper.hint(spans, "Enter", "start");
+ }
+ }
+ TuiHelper.hint(spans, "Esc", "cancel");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
new file mode 100644
index 000000000000..2a6da6f405cd
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreRun.java
@@ -0,0 +1,307 @@
+/*
+ * 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.tui;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.camel.dsl.jbang.core.common.ChaosExperimentRun;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An exploration started from the Chaos screen (let there be chaos,
CAMEL-26760): the picked rules are tried one at a
+ * time, each as an experiment for its share of the budget, and the
exploration is finished at the end. It runs on a
+ * thread of its own, as an experiment waits for its verdict; camel-chaos
enforces the budget, so its rules end at the
+ * deadline also when the TUI is gone. The Explore view shows the experiments
as the integration reports them, and this
+ * says which rule is being tried.
+ */
+final class ChaosExploreRun {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ChaosExploreRun.class);
+ private static final Pattern UNTIL = Pattern.compile("\\buntil=\\S+");
+ private static final Pattern WITHIN = Pattern.compile("\\bwithin=\\S+");
+ // the faults that act on the route once, for their duration, and take no
until=
+ private static final Pattern ONCE =
Pattern.compile("\\s(stall|lifecycle)\\b");
+ // the order of the verdicts in the summary
+ private static final List<String> VERDICTS = List.of("PASS", "FAIL",
"INCONCLUSIVE", "REFUSED");
+ // left over at the deadline, for the last experiment to end before
camel-chaos ends it
+ private static final long MARGIN = 3000;
+ // how much longer than its share an experiment may wait for its verdict
+ private static final long GRACE = 5000;
+
+ // the chaos action and the status of the integration with the pid, for
the experiments
+ private final Function<String, ChaosExperimentRun.Integration>
integrations;
+ private Thread thread;
+ // set on the thread of the run, read when the screen is drawn
+ private volatile String pid;
+ private volatile List<ChaosExploreDialog.Item> picks = List.of();
+ private volatile int current;
+ private volatile long share;
+ private volatile long deadline;
+ private volatile long ruleStarted;
+ private volatile boolean running;
+ private volatile boolean stopped;
+ private volatile String outcome;
+ // why the first rule the integration refused was refused
+ private volatile String refusal;
+ private volatile long started;
+ private volatile String budget;
+ private final Map<String, Integer> verdicts = new TreeMap<>(
+ (a, b) -> Integer.compare(order(a), order(b)) != 0 ?
Integer.compare(order(a), order(b)) : a.compareTo(b));
+
+ ChaosExploreRun(MonitorContext ctx) {
+ this(pid -> integration(ctx, pid));
+ }
+
+ ChaosExploreRun(Function<String, ChaosExperimentRun.Integration>
integrations) {
+ this.integrations = integrations;
+ }
+
+ /** Whether an exploration started here runs in the integration with the
pid. */
+ boolean isRunning(String pid) {
+ return running && pid != null && pid.equals(this.pid);
+ }
+
+ /** Starts the exploration: the picked rules one at a time, each for its
share of the budget. */
+ synchronized void start(String pid, List<ChaosExploreDialog.Item> picks,
String budget, long budgetMillis) {
+ if (running) {
+ return;
+ }
+ this.pid = pid;
+ this.picks = List.copyOf(picks);
+ this.share = budgetMillis / Math.max(1, picks.size());
+ this.started = MonotonicClock.millis();
+ this.ruleStarted = started;
+ this.deadline = started + budgetMillis;
+ this.budget = budget;
+ this.current = 0;
+ this.stopped = false;
+ this.outcome = null;
+ this.refusal = null;
+ synchronized (verdicts) {
+ verdicts.clear();
+ }
+ running = true;
+ thread = new Thread(() -> run(budget), "camel-tui-chaos-explore");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /** Stops the exploration: it is finished now, which removes its rules. */
+ synchronized void stop() {
+ if (!running) {
+ return;
+ }
+ stopped = true;
+ if (thread != null) {
+ // the experiment that waits for its verdict is ended, and the
exploration finished, on the way out
+ thread.interrupt();
+ }
+ }
+
+ /** Runs the exploration, on its own thread. */
+ void run(String budget) {
+ ChaosExperimentRun.Integration integration = integrations.apply(pid);
+ try {
+ integration.chaos("explore-start", root -> root.put("budget",
budget));
+ } catch (RuntimeException e) {
+ outcome = "Not started: " + e.getMessage();
+ running = false;
+ return;
+ }
+ try {
+ for (int i = 0; i < picks.size() && !stopped; i++) {
+ current = i;
+ ruleStarted = MonotonicClock.millis();
+ // the time left, shared by the rules left: an experiment
takes a little longer than its rules run
+ share = Math.max(1000, (deadline - ruleStarted - MARGIN) /
(picks.size() - i));
+ ChaosExploreDialog.Item pick = picks.get(i);
+ String verdict;
+ try {
+ // a file route that does not get messages by itself takes
the files it took again; the rule as it
+ // ran says until= and within= of the share, which the
note explains
+ String note = "Tried for its " + ChaosTab.shortTime(share)
+ " share of the " + budget
+ + " budget: until= and within= are set to
it";
+ JsonObject ended = ChaosExperimentRun.run(integration,
pick.route() + ": " + pick.title(),
+ forShare(pick.rule(), share),
+ pick.replays() ?
ChaosExperimentRun.Feed.replay(pick.replayRoute()) : null,
+ share + GRACE, note);
+ verdict = ended.getString("verdict");
+ if (ended.getLongOrDefault("timeLeft", 1) <= 0) {
+ // the deadline of the exploration: camel-chaos has
ended it
+ count(verdict);
+ break;
+ }
+ } catch (IllegalStateException e) {
+ // interrupted by stop
+ break;
+ } catch (RuntimeException e) {
+ // the integration refused it, such as a rule that is not
valid there: the next one
+ LOG.debug("Chaos experiment {} refused: {}", pick.rule(),
e.getMessage());
+ verdict = "REFUSED";
+ if (refusal == null) {
+ refusal = "rule " + (i + 1) + " refused: " +
e.getMessage();
+ }
+ }
+ count(verdict);
+ }
+ } finally {
+ // a stop interrupted the wait for the verdict: the finish is
still sent
+ Thread.interrupted();
+ try {
+ integration.chaos("explore-finish", root -> {
+ });
+ } catch (RuntimeException e) {
+ // finished already, at its deadline
+ LOG.debug("Chaos exploration finished already: {}",
e.getMessage());
+ }
+ int tried = tried();
+ String counts = tried + (tried == 1 ? " rule" : " rules") + "
tried" + (tried == 0 ? "" : ", " + verdictCounts());
+ outcome = (stopped
+ ? "Stopped after " + counts
+ : "Done after " +
ChaosTab.shortTime(MonotonicClock.millis() - started) + " of " + budget + ": "
+ + counts)
+ + (refusal != null ? " (" + refusal + ")" : "");
+ running = false;
+ }
+ }
+
+ private static int order(String verdict) {
+ int i = VERDICTS.indexOf(verdict);
+ return i >= 0 ? i : VERDICTS.size();
+ }
+
+ private void count(String verdict) {
+ synchronized (verdicts) {
+ verdicts.merge(verdict != null ? verdict : "INCONCLUSIVE", 1,
Integer::sum);
+ }
+ }
+
+ private int tried() {
+ synchronized (verdicts) {
+ return
verdicts.values().stream().mapToInt(Integer::intValue).sum();
+ }
+ }
+
+ private String verdictCounts() {
+ synchronized (verdicts) {
+ return verdicts.entrySet().stream().map(e -> e.getValue() + " " +
e.getKey())
+ .collect(Collectors.joining(", "));
+ }
+ }
+
+ /**
+ * What the exploration started here is doing, for the Explore view of the
integration with the pid: the rule it
+ * tries, or how it ended. Null when none was started here for it.
+ */
+ String progress(String pid) {
+ if (pid == null || !pid.equals(this.pid)) {
+ return null;
+ }
+ if (!running) {
+ return outcome;
+ }
+ if (stopped) {
+ return "Stopping: the rules are removed";
+ }
+ List<ChaosExploreDialog.Item> list = picks;
+ int i = current;
+ if (i >= list.size()) {
+ return null;
+ }
+ ChaosExploreDialog.Item pick = list.get(i);
+ long left = share - (MonotonicClock.millis() - ruleStarted);
+ return "Rule " + (i + 1) + " of " + list.size() + ": " + pick.route()
+ ", " + pick.title()
+ + (left > 0
+ ? ", " + ChaosTab.countdown(left) + " left of " +
ChaosTab.shortTime(share)
+ : ", waiting for the verdict")
+ + (tried() == 0 ? "" : " · " + verdictCounts());
+ }
+
+ /**
+ * The line above the exploration: the rule being tried while it runs;
after it, only what the exploration does not
+ * show itself (a rule the integration refused, or why it did not start).
Null when there is nothing to say.
+ */
+ String line(String pid) {
+ String answer = progress(pid);
+ if (answer == null || running || refusal != null ||
answer.startsWith("Not started")) {
+ return answer;
+ }
+ return null;
+ }
+
+ /**
+ * The rules for their share of the budget: each ends when its share is up
({@code until=}), and an expectation that
+ * waits longer for its messages ({@code within=}) waits that long at
most. A fault that acts once for its duration
+ * (stall, lifecycle) takes no until: its expectation is decided when the
share is up instead.
+ */
+ static String forShare(String rules, long share) {
+ String seconds = Math.max(1, share / 1000) + "s";
+ return Arrays.stream(rules.split(";")).map(String::strip).filter(r ->
!r.isEmpty()).map(r -> {
+ if (ONCE.matcher(r).find()) {
+ if (WITHIN.matcher(r).find()) {
+ return WITHIN.matcher(r).replaceAll("within=" + seconds);
+ }
+ return r.contains(" expect=") ? r + " within=" + seconds : r;
+ }
+ String rule = UNTIL.matcher(r).find() ?
UNTIL.matcher(r).replaceAll("until=" + seconds) : r + " until=" + seconds;
+ return WITHIN.matcher(rule).replaceAll("within=" + seconds);
+ }).collect(Collectors.joining("; "));
+ }
+
+ /** The integration with the pid, as the TUI reaches it: its chaos action,
and its status file. */
+ private static ChaosExperimentRun.Integration integration(MonitorContext
ctx, String pid) {
+ return new ChaosExperimentRun.Integration() {
+ @Override
+ public JsonObject chaos(String command, Consumer<JsonObject>
options) {
+ JsonObject request = new JsonObject();
+ request.put("action", "chaos");
+ request.put("command", command);
+ options.accept(request);
+ JsonObject reply = ctx.executeAction(pid, request, 10000);
+ if (reply == null) {
+ throw new IllegalArgumentException("The integration did
not answer");
+ }
+ if (reply.getString("error") != null) {
+ throw new
IllegalArgumentException(reply.getString("error"));
+ }
+ return reply;
+ }
+
+ @Override
+ public JsonObject send(ChaosExperimentRun.Feed feed) {
+ // the exploration of the TUI sends no messages: its routes
get them by themselves, or take the files they
+ // took again (a replay, which is a chaos command)
+ throw new UnsupportedOperationException("No feed");
+ }
+
+ @Override
+ public JsonObject status() {
+ return RuntimeHelper.readStatus(Long.parseLong(pid));
+ }
+ };
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
index df2761924eb2..8c3e942a0a9e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreView.java
@@ -57,16 +57,25 @@ final class ChaosExploreView {
/**
* Renders the exploration.
*
- * @return the area of the table of experiments, for the clicks
+ * @param progress what the exploration started on this screen is doing
(L), or null
+ * @return the area of the table of experiments, for the clicks
*/
- static Rect render(Frame frame, Rect area, ChaosInfo.Exploration ex,
TableState state, String name) {
+ static Rect render(
+ Frame frame, Rect area, ChaosInfo.Exploration ex, TableState
state, String name, String progress) {
+ if (progress != null) {
+ List<Rect> rows =
Layout.vertical().constraints(Constraint.length(1),
Constraint.fill()).split(area);
+ frame.renderWidget(Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(" 🎲 " + progress,
Theme.warning())))).build(), rows.get(0));
+ area = rows.get(1);
+ }
if (ex == null) {
frame.renderWidget(Paragraph.builder()
.text(Text.from(Line.from(Span.raw("")),
- Line.from(Span.styled(" No exploration yet. Let
there be chaos: an AI tries faults on the"
- + " integration by itself
for a budget of time, and reports",
+ Line.from(Span.styled(" No exploration yet. Let
there be chaos: press L to pick the rules to"
+ + " try, and they are tried
one at a time for a budget of time.",
Theme.muted())),
- Line.from(Span.styled(" what held and what broke:
camel cmd chaos-explore "
+ Line.from(Span.styled(" Or let an AI try faults
by itself, and report what held and what"
+ + " broke: camel cmd
chaos-explore "
+ (name != null ? name :
"<name>") + " --budget=10m",
Theme.muted()))))
.block(block(" Explore "))
@@ -100,8 +109,10 @@ final class ChaosExploreView {
+ (ex.ran() > 0 ? " after " +
ChaosTab.shortTime(ex.ran()) : "")
+ " (budget " + ChaosTab.shortTime(ex.budget())
+ ")"));
}
- first.add(Span.styled(" focus ", Theme.muted()));
- first.add(Span.raw(ex.focus() != null ? ex.focus() : "all routes"));
+ if (ex.focus() != null) {
+ first.add(Span.styled(" focus ", Theme.muted()));
+ first.add(Span.raw(ex.focus()));
+ }
first.add(Span.styled(" experiments ", Theme.muted()));
first.add(Span.raw(String.valueOf(ex.experiments().size())));
long pass = ex.count("PASS");
@@ -205,6 +216,8 @@ final class ChaosExploreView {
.build(), area);
return;
}
+ // the width of the evidence column (2 of the 5 parts the last two
share), for a clean cut
+ int evidenceWidth = Math.max(10, (area.width() - 2 - 3 - 3 - 6 - 13 -
4) * 2 / 5);
List<Row> rows = new ArrayList<>();
for (ChaosInfo.Experiment e : ex.experiments()) {
rows.add(Row.from(
@@ -212,7 +225,7 @@ final class ChaosExploreView {
Cell.from(Span.styled(clock(e.at()), Theme.muted())),
Cell.from(Span.styled(verdict(e), verdictStyle(e).bold())),
Cell.from(e.hypothesis() != null ? e.hypothesis() :
String.join("; ", e.rules())),
- Cell.from(Span.styled(evidence(e), Theme.muted()))));
+ Cell.from(Span.styled(cut(evidence(e), evidenceWidth),
Theme.muted()))));
}
Style bold = Style.EMPTY.bold();
Table table = Table.builder()
@@ -255,19 +268,53 @@ final class ChaosExploreView {
sj.add(e.lostAtDlc() + " lost at the dead letter channel");
}
for (String x : e.expectations()) {
- // orders:failed<=0: unmet, 5 failed -> 5 failed in orders
+ // orders:failed<=0: unmet, 5 failed -> 5 failed in orders; the
counts in parentheses are in the detail
int colon = x.indexOf(": ");
int comma = x.indexOf(", ", colon + 2);
String route = x.contains(":") ? x.substring(0, x.indexOf(':')) :
"";
if (comma > 0) {
String value = x.substring(comma + 2);
- sj.add(value + (route.isEmpty() || value.contains(route) ? ""
: " in " + route));
+ int paren = value.indexOf(" (");
+ value = paren > 0 ? value.substring(0, paren) : value;
+ int arrow = route.indexOf("->");
+ if (arrow > 0) {
+ // inbox->ship:lost<=0
+ sj.add(value + " from " + route.substring(0, arrow) + " to
" + route.substring(arrow + 2));
+ } else {
+ sj.add(value + (route.isEmpty() || value.contains(route) ?
"" : " in " + route));
+ }
}
}
- e.alsoFailed().forEach((route, n) -> sj.add("also " + n + " failed in
" + route));
+ e.alsoFailed().forEach((route, n) -> {
+ if (handedOffTo(e, route) == null) {
+ sj.add("also " + n + " failed in " + route);
+ }
+ });
return sj.toString();
}
+ /**
+ * The route that handed the messages over to the route, when an
expectation of the experiment counts what is lost
+ * between them ({@code inbox->ship:lost<=0}): the failures of the route
are then where the lost messages failed.
+ * Null otherwise.
+ */
+ static String handedOffTo(ChaosInfo.Experiment e, String route) {
+ for (String x : e.expectations()) {
+ int arrow = x.indexOf("->");
+ int colon = x.indexOf(':');
+ if (arrow > 0 && colon > arrow && x.substring(arrow + 2,
colon).equals(route)
+ && x.startsWith(":lost", colon)) {
+ return x.substring(0, arrow);
+ }
+ }
+ return null;
+ }
+
+ /** The text cut to the width, with … where it was cut. */
+ static String cut(String text, int width) {
+ return text.length() <= width ? text : text.substring(0, Math.max(0,
width - 1)).stripTrailing() + "…";
+ }
+
// ---- the selected experiment ----
private static void renderDetail(Frame frame, Rect area,
ChaosInfo.Experiment e) {
@@ -302,11 +349,27 @@ final class ChaosExploreView {
+ " at the dead letter channel, handled there
without being delivered",
Theme.error())));
}
- if (!e.alsoFailed().isEmpty()) {
- StringJoiner sj = new StringJoiner(", ");
- e.alsoFailed().forEach((route, n) -> sj.add(route + " " + n));
+ StringJoiner also = new StringJoiner(", ");
+ e.alsoFailed().forEach((route, n) -> {
+ String from = handedOffTo(e, route);
+ if (from != null) {
+ // the same messages as the lost ones: route ship failed them
after inbox had completed
+ lines.add(Line.from(Span.styled(" where ", label),
+ Span.styled(n + (n == 1 ? " message" : " messages") +
" failed in " + route + ", after " + from
+ + " had handed them over and completed:
they are the lost ones",
+ Theme.error())));
+ } else {
+ also.add(route + " " + n);
+ }
+ });
+ if (also.length() > 0) {
lines.add(Line.from(Span.styled(" also ", label),
- Span.styled("failed, in no expectation: " + sj,
Theme.warning())));
+ Span.styled("failed, in no expectation: " + also,
Theme.warning())));
+ }
+ if (!e.routes().isEmpty()) {
+ // the expectations count until they are decided, the routes until
the experiment ended: a little more
+ lines.add(Line.from(Span.styled(" routes ", label),
+ Span.styled("over the whole experiment (an expectation
counts until it is decided)", label)));
}
for (String r : e.routes()) {
lines.add(Line.from(Span.styled(" route ", label),
Span.raw(r)));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
index b9733e122b76..6b557dba32e9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
@@ -67,6 +67,8 @@ class ChaosTab extends AbstractTableTab {
// the exploration instead of the rules (l, let there be chaos), and the
selected rule meanwhile
private boolean explore;
private Integer rulesSelection;
+ // the selection follows the experiment that runs, of an exploration
started here, until another one is selected
+ private boolean followRunning;
ChaosTab(MonitorContext ctx) {
this(ctx, new ChaosControl(ctx));
@@ -125,11 +127,20 @@ class ChaosTab extends AbstractTableTab {
toggleExplore();
return true;
}
- if (explore) {
- // the exploration is read-only: the keys of the rules do not act
on it
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (ke.isChar('L') && control.step() == ChaosControl.Step.NONE) {
+ // let there be chaos: pick the rules to try, for a budget of time
(CAMEL-26760)
+ control.openExplore(info);
+ return true;
+ }
+ if (explore && control.step() == ChaosControl.Step.NONE) {
+ // the exploration is read-only: the keys of the rules do not act
on it, but one started here can be stopped
+ if (ke.isChar('x') && info != null &&
control.explorer().isRunning(info.pid)) {
+ control.explorer().stop();
+ return true;
+ }
return false;
}
- IntegrationInfo info = ctx.findSelectedIntegration();
ChaosInfo.Rule rule = info != null && info.chaos != null ?
selectedRule(info.chaos) : null;
if (ke.isKey(KeyCode.F8) && ke.hasShift() && control.step() ==
ChaosControl.Step.NONE) {
// explain with AI what the selected rule did (CAMEL-26702)
@@ -144,7 +155,15 @@ class ChaosTab extends AbstractTableTab {
ctx.askAiCallback.ask(ChaosAiPrompt.ofTest(info, rule));
return true;
}
- return control.handleKey(ke, info, rule);
+ boolean used = control.handleKey(ke, info, rule);
+ if (control.takeExploreStarted()) {
+ // the exploration that was just started
+ if (!explore) {
+ toggleExplore();
+ }
+ followRunning = true;
+ }
+ return used;
}
@Override
@@ -190,6 +209,7 @@ class ChaosTab extends AbstractTableTab {
@Override
public void navigateUp() {
if (!control.isOpen()) {
+ followRunning = false;
super.navigateUp();
}
}
@@ -197,6 +217,7 @@ class ChaosTab extends AbstractTableTab {
@Override
public void navigateDown() {
if (!control.isOpen()) {
+ followRunning = false;
super.navigateDown();
}
}
@@ -227,11 +248,19 @@ class ChaosTab extends AbstractTableTab {
.text(Text.from(Line.from(Span.raw("")),
Line.from(Span.styled(" " + why, Theme.muted()))))
.block(block(" Chaos "))
.build(), area);
+ // such as why L does nothing here
+ control.render(frame, area);
return;
}
if (explore) {
- lastTableArea = ChaosExploreView.render(frame, area,
chaos.exploration, tableState, info.name);
+ int experiments = chaos.exploration != null ?
chaos.exploration.experiments().size() : 0;
+ if (followRunning && experiments > 0 &&
control.explorer().isRunning(info.pid)) {
+ tableState.select(experiments - 1);
+ }
+ lastTableArea = ChaosExploreView.render(frame, area,
chaos.exploration, tableState, info.name,
+ control.explorer().line(info.pid));
lastDetailArea = null;
+ control.render(frame, area);
return;
}
int tableHeight = Math.max(5, Math.min(chaos.rules.size() + 3,
area.height() / 3));
@@ -849,14 +878,26 @@ class ChaosTab extends AbstractTableTab {
hint(spans, "Esc", "back");
return;
}
- if (explore) {
+ if (explore && control.step() == ChaosControl.Step.NONE) {
hint(spans, "↑↓", "experiment");
+ if (control.explorer().isRunning(info.pid)) {
+ hint(spans, "x", "stop");
+ } else {
+ hint(spans, "L", "let there be chaos");
+ }
hint(spans, "l", "rules");
hint(spans, "Esc", "back");
return;
}
+ if (explore) {
+ // the keys of the open dialog
+ control.renderFooter(spans, false);
+ return;
+ }
control.renderFooter(spans, !info.chaos.rules.isEmpty());
- hint(spans, "l", "explore");
+ if (control.step() == ChaosControl.Step.NONE) {
+ hint(spans, "l", "explore");
+ }
}
@Override
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
index 48e0f8ee4d6c..25aaffcfc818 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DialogHelper.java
@@ -168,15 +168,20 @@ final class DialogHelper {
return renderConfirm(frame, area, title, message, detail, acceptKey,
acceptLabel, extraKey, extraLabel, null);
}
- /** As above, recording where the keys of the dialog are drawn in {@code
clicks} (when not null). */
+ /**
+ * As above, recording where the keys of the dialog are drawn in {@code
clicks} (when not null). Without an
+ * {@code acceptKey} it only waits, such as while the integration is
asked: Esc cancels.
+ */
static Rect renderConfirm(
Frame frame, Rect area, String title, String message, String
detail, String acceptKey, String acceptLabel,
String extraKey, String extraLabel, HintClicks clicks) {
Style accent = Theme.warning();
String titleText = " " + title + " ";
String msg = message.trim();
- Line hints = extraKey != null
- ? TuiHelper.hintLine(acceptKey, acceptLabel, extraKey,
extraLabel, "Esc", "cancel")
+ Line hints = acceptKey == null
+ ? TuiHelper.hintLine("Esc", "cancel")
+ : extraKey != null
+ ? TuiHelper.hintLine(acceptKey, acceptLabel, extraKey,
extraLabel, "Esc", "cancel")
: TuiHelper.hintLine(acceptKey, acceptLabel, "Esc", "cancel");
int contentW = Math.max(CharWidth.of(msg), Math.max(detail != null ?
CharWidth.of(detail) : 0, hints.width()));
int popupW = clampWidth(area, CONFIRM_MIN_WIDTH, Math.max(contentW +
6, CharWidth.of(titleText) + 4));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
index d9cfabb1b6ae..98b4085650a9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -76,6 +76,25 @@ at a dead letter channel, the routes that failed outside its
expectations,
and why it could not decide (such as faults that never fired). **l** or
Esc shows the rules again.
+**L** starts an exploration here, by hand: pick the rules to try from the
+suggestions of the integration (the same as **w**), the most worth trying
+first, each with what should hold despite its faults. Picked are a few
+faults not tried yet, each kind once before it is tried again in another
+route, and then two faults at once (an outage while the dead letter
+channel fails); only of routes that get messages by themselves (a timer,
+or a route they are called from), or that take files and took some
+already: those are marked *replays 3 files*, and while the rule runs the
+files the route moved to `.camel` are put back into its folder as it
+takes them, the same files each time. A route whose messages come from
+outside is marked *needs messages*: send it some while its rule runs, or
+its verdict says nothing. Space picks a rule, Left and Right change the
+budget (2m to 30m), and Enter starts. The picked rules are tried one at a
+time, each for its share of the budget, and each ends with PASS, FAIL or
+INCONCLUSIVE; the line above the experiments says which rule is tried and
+how long it has left. **x** stops it: the rules are removed at once. Every
+rule is removed when the budget ends, also when the TUI is closed, and the
+routes are not changed. One exploration runs at a time.
+
## Keys
- **a** — Add a rule: pick what should go wrong from the known rules of
@@ -96,6 +115,10 @@ Esc shows the rules again.
was tried in the route and what is not tried yet
- **e** — Change the selected rule: the line starts with the rule as it is
- **l** — Explore: the exploration and its experiments, or the rules again
+- **L** — Let there be chaos: pick the rules to try and the budget, and
+ they are tried one at a time (see Explore above). In the dialog: Space
+ picks, Left and Right change the budget, Enter starts
+- **x** — On the exploration: stop the exploration started with **L**
- **x** — Remove the selected rule
- **c** — Clear: remove all rules (the report keeps what they did)
- **R** — A new experiment: no rules and a new report, with the same seed
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
new file mode 100644
index 000000000000..8fa1af8c414c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosExploreTest.java
@@ -0,0 +1,356 @@
+/*
+ * 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.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.dsl.jbang.core.common.ChaosExperimentRun;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Let there be chaos from the Chaos screen ({@code L}): the rules to pick
from the suggestions of the integration, the
+ * budget, and the exploration that tries the picked rules one at a time.
+ */
+class ChaosExploreTest {
+
+ private static final String SUGGESTIONS = """
+ {"suggestions": [
+ {"route": "orders", "kind": "errors", "title": "Returns an error
(HTTP status)",
+ "rule": "endpoint=http://localhost:8080/payment error
status=503 rate=0.25 until=10m expect=orders:failed<=0",
+ "why": "an HTTP call without a circuit breaker", "feed":
{"kind": "self", "fed": true}},
+ {"route": "orders", "kind": "lost", "title": "The route after a
hand-off fails",
+ "rule": "route=shipping error rate=0.2 until=2m
expect=orders->shipping:lost<=0 within=3m",
+ "why": "a hand-off over seda", "feed": {"kind": "self", "fed":
true}},
+ {"route": "refunds", "kind": "consumer", "title": "The route
fails",
+ "rule": "route=refunds error rate=0.05 until=10m
expect=refunds:failed<=0",
+ "why": "an input from outside", "feed": {"kind": "send"}},
+ {"route": "payment", "kind": "lifecycle", "title": "The route
stalls",
+ "rule": "route=payment stall duration=20s
expect=payment:failed<=0", "tried": true,
+ "why": "stops taking messages", "feed": {"kind": "http", "fed":
true}},
+ {"route": "orders", "kind": "dead letter channel", "title": "The
dead letter channel is unreachable",
+ "rule": "endpoint=direct:parked error at=dlq
expect=orders:failed<=0",
+ "why": "fails only when another fault sends it a message",
"feed": {"kind": "self", "fed": true}},
+ {"route": "orders", "kind": "combined", "title": "An outage
while the dead letter channel fails",
+ "rule": "endpoint=http://localhost:8080/payment error
status=503 until=1m expect=orders:failed<=0",
+ "rules": ["endpoint=http://localhost:8080/payment error
status=503 until=1m expect=orders:failed<=0",
+ "endpoint=direct:parked error at=dlq until=1m"],
+ "why": "two faults at once", "feed": {"kind": "self", "fed":
true}}
+ ]}
+ """;
+
+ private IntegrationInfo info;
+ private ChaosTab tab;
+ private FakeIntegration integration;
+ private ChaosExploreRun explorer;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Theme.resetForTesting();
+ info = new IntegrationInfo();
+ info.pid = "1234";
+ info.name = "shop";
+ info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+ {"enabled": true, "rules": [], "report": {"verdict": "PASS",
"final": true, "seed": 42}}
+ """));
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+ ctx.selectedPid = "1234";
+ integration = new FakeIntegration();
+ explorer = new ChaosExploreRun(pid -> integration);
+ JsonObject suggestions = (JsonObject) Jsoner.deserialize(SUGGESTIONS);
+ tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> suggestions,
() -> null, explorer));
+ TuiTestHelper.renderToString(tab, 160, 40);
+ }
+
+ private static ChaosExploreDialog dialog() throws Exception {
+ return new ChaosExploreDialog("shop", (JsonObject)
Jsoner.deserialize(SUGGESTIONS));
+ }
+
+ private void key(char ch) {
+ tab.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+
+ private void press(KeyCode code) {
+ tab.handleKeyEvent(KeyEvent.ofKey(code, KeyModifiers.NONE));
+ }
+
+ private String footer() {
+ List<Span> spans = new ArrayList<>();
+ tab.renderFooter(spans);
+ return spans.stream().map(Span::content).reduce("", String::concat);
+ }
+
+ @Test
+ void theFaultsNotTriedWhoseRoutesGetMessagesArePicked() throws Exception {
+ ChaosExploreDialog dialog = dialog();
+ assertThat(dialog.items()).hasSize(6);
+ // not refunds (its messages come from outside), not the stall
(tried), not the dead letter channel on its own
+ // (it fails only when another fault sends it a message), and the pair
after the single ones
+ assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::title)
+ .containsExactly("Returns an error (HTTP status)", "The route
after a hand-off fails",
+ "An outage while the dead letter channel fails");
+
assertThat(dialog.items().get(0).expect()).isEqualTo("orders:failed<=0");
+ assertThat(dialog.items().get(5).rule()).contains(";
endpoint=direct:parked error at=dlq");
+ // 10 minutes for three rules
+ assertThat(dialog.budget()).isEqualTo("10m");
+ assertThat(dialog.share()).isEqualTo(200_000);
+ }
+
+ @Test
+ void spacePicksAndTheArrowsChangeTheBudget() throws Exception {
+ ChaosExploreDialog dialog = dialog();
+ dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ dialog.handleKey(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+ assertThat(dialog.picked()).hasSize(4);
+ dialog.handleKey(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ dialog.handleKey(KeyEvent.ofKey(KeyCode.LEFT, KeyModifiers.NONE));
+ assertThat(dialog.budget()).isEqualTo("2m");
+ assertThat(dialog.share()).isEqualTo(30_000);
+ assertThat(dialog.cannotStart()).isNull();
+
+ // too many rules for the budget: each needs time for its faults and
messages
+ dialog.handleKey(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ dialog.handleKey(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+ assertThat(dialog.picked()).hasSize(5);
+ assertThat(dialog.cannotStart()).startsWith("Too many rules for 2m");
+ assertThat(dialog.handleKey(KeyEvent.ofKey(KeyCode.ENTER,
KeyModifiers.NONE)))
+ .isEqualTo(ChaosExploreDialog.Outcome.NONE);
+ }
+
+ @Test
+ void eachRuleRunsForItsShare() {
+ assertThat(ChaosExploreRun.forShare(
+ "route=shipping error rate=0.2 until=2m
expect=orders->shipping:lost<=0 within=3m", 150_000))
+ .isEqualTo("route=shipping error rate=0.2 until=150s
expect=orders->shipping:lost<=0 within=150s");
+ // a stall acts once, for its duration: no until, and its expectation
is decided when the share is up
+ assertThat(ChaosExploreRun.forShare("route=payment stall duration=20s
expect=orders:failed<=0", 60_000))
+ .isEqualTo("route=payment stall duration=20s
expect=orders:failed<=0 within=60s");
+ // two faults at once: both end with the share
+ assertThat(ChaosExploreRun.forShare("endpoint=a error until=1m;
endpoint=b error at=dlq until=1m", 30_000))
+ .isEqualTo("endpoint=a error until=30s; endpoint=b error
at=dlq until=30s");
+ }
+
+ @Test
+ void letThereBeChaosTriesThePickedRulesOneAtATime() {
+ key('L');
+ assertThat(tab.isTextInputActive()).isTrue();
+ String screen = TuiTestHelper.renderToString(tab, 160, 40);
+ assertThat(screen).contains("Let there be chaos: shop", "Budget",
"10m", "3 rules, 3m20s each",
+ "[x] Returns an error (HTTP status)", "[ ] The route fails",
"needs messages");
+ assertThat(footer()).contains("Space", "pick", "budget", "Enter",
"start");
+
+ press(KeyCode.ENTER);
+ // the exploration shows
+ assertThat(tab.isExplore()).isTrue();
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!explorer.isRunning("1234"));
+ assertThat(integration.commands).containsExactly(
+ "explore-start", "experiment-begin", "experiment-end",
"experiment-begin", "experiment-end",
+ "experiment-begin", "experiment-end", "explore-finish");
+ assertThat(integration.budget).isEqualTo("10m");
+ // each rule for its share of the time left (the fake ends each
experiment at once, so the shares grow)
+ assertThat(integration.rules).hasSize(3);
+ assertThat(integration.rules.get(0)).matches(
+ "endpoint=http://localhost:8080/payment error status=503
rate=0\\.25 until=19\\ds expect=orders:failed<=0");
+ assertThat(integration.rules.get(1)).matches(
+ "route=shipping error rate=0\\.2 until=29\\ds
expect=orders->shipping:lost<=0 within=29\\ds");
+ // two faults at once: both end with the share
+ assertThat(integration.rules.get(2)).matches(
+ "endpoint=http://localhost:8080/payment error status=503
until=59\\ds expect=orders:failed<=0; "
+ + "endpoint=direct:parked
error at=dlq until=59\\ds");
+ assertThat(integration.hypotheses).containsExactly(
+ "orders: Returns an error (HTTP status)", "orders: The route
after a hand-off fails",
+ "orders: An outage while the dead letter channel fails");
+ assertThat(explorer.progress("1234")).matches("Done after \\d+ms of
10m: 3 rules tried, 3 FAIL");
+ // each says it ran for its share, as its rule says until= and within=
of the share
+ assertThat(integration.notes).hasSize(3)
+ .allMatch(n -> n
+ .matches("Tried for its (\\d+m)?\\d+s share of the 10m
budget: until= and within= are set to it"));
+ // the exploration shows how it went, so no line above it says it again
+ assertThat(explorer.line("1234")).isNull();
+ assertThat(TuiTestHelper.renderToString(tab, 160,
40)).doesNotContain("🎲");
+ }
+
+ @Test
+ void aFileRouteTakesTheFilesItTookAgain() throws Exception {
+ ChaosExploreDialog dialog = new ChaosExploreDialog(
+ "shop", (JsonObject) Jsoner.deserialize(
+ """
+ {"suggestions": [
+ {"route": "inbox", "kind": "errors",
"title": "Fails to write",
+ "rule": "endpoint=file://archive error
exception=java.io.IOException rate=0.25 until=10m expect=inbox:failed<=0",
+ "why": "a write", "feed": {"kind": "send",
"replay": "/work/inbox/.camel", "files": 3, "replayRoute": "files"}},
+ {"route": "uploads", "kind": "consumer",
"title": "The route fails",
+ "rule": "route=uploads error rate=0.05
until=10m expect=uploads:failed<=0",
+ "why": "an input from outside", "feed":
{"kind": "file"}}
+ ]}
+ """));
+ // the files it took are put back, so it has messages; a route that
took none needs them from outside
+
assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::route).containsExactly("inbox");
+ assertThat(dialog.items().get(0).replay()).isEqualTo(3);
+ Rect area = new Rect(0, 0, 160, 30);
+ Buffer buffer = Buffer.empty(area);
+ dialog.render(Frame.forTesting(buffer), area);
+ assertThat(TuiTestHelper.bufferToString(buffer))
+ .contains("[x] Fails to write inbox replays 3 files", "[ ]
The route fails uploads needs messages");
+
+ explorer.start("1234", dialog.picked(), "2m", 120_000);
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!explorer.isRunning("1234"));
+ // after the rule is added, so its faults meet the files
+ assertThat(integration.commands).containsExactly(
+ "explore-start", "experiment-begin", "replay",
"experiment-end", "explore-finish");
+ // the file route that sends to it
+ assertThat(integration.replayed).containsExactly("files");
+ }
+
+ @Test
+ void stopEndsTheExploration() {
+ integration.decided = false;
+ key('L');
+ press(KeyCode.ENTER);
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
integration.commands.contains("experiment-begin"));
+ assertThat(footer()).contains("x", "stop");
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Rule
1 of 3: orders, Returns an error");
+
+ key('x');
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!explorer.isRunning("1234"));
+ // the experiment is ended and the exploration finished, which removes
the rules
+ assertThat(integration.commands).endsWith("experiment-end",
"explore-finish");
+ assertThat(explorer.progress("1234")).startsWith("Stopped after 0
rules tried");
+ assertThat(footer()).contains("L", "let there be chaos");
+ }
+
+ @Test
+ void aKindOfFaultOnceBeforeItIsTriedAgainInAnotherRoute() throws Exception
{
+ JsonObject reply = (JsonObject) Jsoner.deserialize("""
+ {"suggestions": [
+ {"route": "small", "kind": "lost", "title": "The route after
a hand-off fails",
+ "rule": "route=a error expect=small->a:lost<=0", "feed":
{"kind": "self", "fed": true}},
+ {"route": "medium", "kind": "lost", "title": "The route
after a hand-off fails",
+ "rule": "route=b error expect=medium->b:lost<=0", "feed":
{"kind": "self", "fed": true}},
+ {"route": "large", "kind": "lost", "title": "The route after
a hand-off fails",
+ "rule": "route=c error expect=large->c:lost<=0", "feed":
{"kind": "self", "fed": true}},
+ {"route": "small", "kind": "errors", "title": "Returns an
error (HTTP status)",
+ "rule": "endpoint=http://x error status=503
expect=small:failed<=0", "feed": {"kind": "self", "fed": true}}
+ ]}
+ """);
+ ChaosExploreDialog dialog = new ChaosExploreDialog("shop", reply);
+ // the HTTP error before a second hand-off, then the hand-offs again
while there is room
+ assertThat(dialog.picked()).extracting(ChaosExploreDialog.Item::route,
ChaosExploreDialog.Item::kind)
+ .containsExactly(tuple("small", "lost"), tuple("medium",
"lost"), tuple("small", "errors"));
+ }
+
+ @Test
+ void lSaysWhyWhenChaosIsOff() {
+ info.chaos = null;
+ key('L');
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40))
+ .contains("camel-chaos is not in shop (yet): run it with camel
run --chaos");
+ }
+
+ @Test
+ void oneExplorationAtATime() throws Exception {
+ info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+ {"enabled": true, "rules": [],
+ "report": {"verdict": "PASS", "final": false, "seed": 42,
+ "exploration": {"running": true, "budget": 600000,
"timeLeft": 300000, "experiments": []}}}
+ """));
+ key('L');
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("An
exploration runs in shop already");
+ }
+
+ /** The integration, as the chaos action and the status answer: every
experiment fails at once. */
+ private static final class FakeIntegration implements
ChaosExperimentRun.Integration {
+ final List<String> commands = new CopyOnWriteArrayList<>();
+ final List<String> rules = new CopyOnWriteArrayList<>();
+ final List<String> hypotheses = new CopyOnWriteArrayList<>();
+ final List<String> replayed = new CopyOnWriteArrayList<>();
+ final List<String> notes = new CopyOnWriteArrayList<>();
+ volatile String budget;
+ volatile boolean decided = true;
+ private volatile int number;
+
+ @Override
+ public JsonObject chaos(String command, Consumer<JsonObject> options) {
+ JsonObject request = new JsonObject();
+ options.accept(request);
+ commands.add(command);
+ JsonObject answer = new JsonObject();
+ switch (command) {
+ case "explore-start" -> budget = request.getString("budget");
+ case "experiment-begin" -> {
+ number++;
+ rules.add(request.getString("rule"));
+ hypotheses.add(request.getString("hypothesis"));
+ answer.put("number", number);
+ }
+ case "replay" -> replayed.add(request.getString("route"));
+ case "experiment-end" -> {
+ notes.add(request.getString("note"));
+ answer.put("number", number);
+ answer.put("verdict", "FAIL");
+ answer.put("open", false);
+ }
+ default -> {
+ // finished
+ }
+ }
+ return answer;
+ }
+
+ @Override
+ public JsonObject send(ChaosExperimentRun.Feed feed) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public JsonObject status() {
+ JsonObject experiment = new JsonObject();
+ experiment.put("number", number);
+ experiment.put("verdict", decided ? "FAIL" : "PENDING");
+ experiment.put("open", !decided);
+ JsonObject exploration = new JsonObject();
+ exploration.put("experiments", List.of(experiment));
+ exploration.put("timeLeft", 60_000);
+ JsonObject report = new JsonObject();
+ report.put("exploration", exploration);
+ JsonObject chaos = new JsonObject();
+ chaos.put("report", report);
+ JsonObject status = new JsonObject();
+ status.put("chaos", chaos);
+ return status;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
index d29bd6393460..f06897fbfcc7 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
@@ -285,6 +285,43 @@ class ChaosTabRenderTest {
assertThat(ex.elapsed()).isEqualTo(300_000);
}
+ @Test
+ void theMessagesLostAtAHandOff() throws Exception {
+ JsonObject chaos = (JsonObject) Jsoner.deserialize(CHAOS);
+ ((JsonObject) chaos.get("report")).put("exploration",
Jsoner.deserialize("""
+ {"running": false, "finishedBy": "deadline", "startedAt":
1000000, "budget": 120000, "ran": 120000,
+ "experiments": [
+ {"number": 1, "hypothesis": "inbox: The route after a
hand-off fails", "verdict": "FAIL",
+ "open": false, "startedAt": 1001000, "elapsed": 118000,
+ "ruleTexts": ["route=ship error rate=0.2 until=116s
expect=inbox->ship:lost<=0 within=116s"],
+ "faults": {"r1": 45},
+ "expectations": [{"rule": "r1", "expect":
"inbox->ship:lost<=0", "state": "unmet",
+ "value": "45 lost (231 completed in
inbox, 186 in ship)"}],
+ "routes": [{"route": "inbox", "completed": 234, "failed":
0},
+ {"route": "ship", "completed": 189, "failed":
45}],
+ "alsoFailed": {"ship": 45, "audit": 2},
+ "note": "Tried for its 1m56s share of the 2m budget:
until= and within= are set to it"}
+ ]}
+ """));
+ info.chaos = ChaosInfo.parse(chaos);
+ ChaosInfo.Experiment e = info.chaos.exploration.experiments().get(0);
+ // short: the counts are in the detail, and the failures in ship are
where the lost ones failed
+ assertThat(ChaosExploreView.evidence(e)).isEqualTo("45 lost from inbox
to ship; also 2 failed in audit");
+ assertThat(ChaosExploreView.handedOffTo(e, "ship")).isEqualTo("inbox");
+ assertThat(ChaosExploreView.handedOffTo(e, "audit")).isNull();
+ assertThat(ChaosExploreView.cut("45 lost from inbox to ship",
10)).isEqualTo("45 lost f…");
+
+ ChaosTab tab = new ChaosTab(ctx);
+ tab.toggleExplore();
+ String screen = TuiTestHelper.renderToString(tab, 160, 44);
+ assertThat(screen).contains("finished at its
deadline").doesNotContain("focus")
+ .contains("45 messages failed in ship, after inbox had handed
them over and completed: they are the lost"
+ + " ones")
+ .contains("failed, in no expectation: audit
2").doesNotContain("in no expectation: ship")
+ .contains("over the whole experiment (an expectation counts
until it is decided)")
+ .contains("Tried for its 1m56s share of the 2m budget");
+ }
+
@Test
void withoutCamelChaosOrWhenItIsOff() throws Exception {
String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);