This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26695-chaos-recipes in repository https://gitbox.apache.org/repos/asf/camel.git
commit f521fba5183fde95e59b6e9844312e710e22746b Author: Claus Ibsen <[email protected]> AuthorDate: Fri Nov 6 16:42:16 2026 +0100 CAMEL-26695: camel-jbang - chaos screen: add a rule from the known rules of the chaos model, with values picked from the integration Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/images/jbang/camel-tui-chaos-recipes.png | Bin 0 -> 420859 bytes .../ROOT/pages/camel-jbang-tui-observe.adoc | 11 +- .../dsl/jbang/core/commands/tui/ChaosControl.java | 70 ++++- .../jbang/core/commands/tui/ChaosRecipeForm.java | 334 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/ChaosRecipes.java | 204 +++++++++++++ .../src/main/resources/tui/help/chaos.md | 8 +- .../core/commands/tui/ChaosRecipeFormTest.java | 174 +++++++++++ .../jbang/core/commands/tui/ChaosRecipesTest.java | 147 +++++++++ 8 files changed, 942 insertions(+), 6 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-recipes.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-recipes.png new file mode 100644 index 000000000000..61ce3a1d58ff Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-recipes.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 be83c83f8c23..c2b2a32e5b12 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 @@ -263,8 +263,15 @@ on purpose is not debugged as a real one. The rules are added, changed and removed on the tab, as with `camel cmd chaos-add`, `chaos-change`, `chaos-remove` and `chaos-clear` (see xref:next@components:others:chaos.adoc#_control_while_it_runs[Control while it runs]): -* *a* adds a rule, typed as one line (`endpoint=kafka:orders* error rate=0.1 until=5m`), and *e* changes the selected -rule, starting from the rule as it is. +* *a* adds a rule. It lists the known rules of camel-chaos by what goes wrong (Downstream: returns an error, is slow, +times out, did the work and then failed; Messages; Consumers and routes; Error handling), from the chaos model of the +catalog. For the one chosen, a field per value of its rule: the endpoints, routes and steps of the integration are +picked with Left and Right (for "Returns an error (HTTP status)", the HTTP endpoints the routes call), the other +values start with a default, and the rule is shown as it is built. The last entry types the rule as one line +(`endpoint=kafka:orders* error rate=0.1 until=5m`). ++ +image::jbang/camel-tui-chaos-recipes.png[Adding a chaos rule from the known rules: the HTTP endpoint of the route is picked] +* *e* changes the selected rule, starting from the rule as it is. * *x* removes the selected rule, *c* removes all rules (the report keeps what they did), and *R* starts a new experiment: no rules and a new report, with the same seed. 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 20b13bfab658..701cb2e422ed 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 @@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; import java.util.List; import java.util.function.Function; +import java.util.function.Supplier; import dev.tamboui.layout.Rect; import dev.tamboui.style.Overflow; @@ -27,6 +28,7 @@ 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; @@ -50,6 +52,8 @@ final class ChaosControl { /** What the control shows: nothing, the rule being typed, the question, or the outcome to read. */ enum Step { NONE, + // the known rules to pick from, and the form of the one picked + RECIPES, INPUT, CONFIRM, RESULT @@ -67,6 +71,11 @@ final class ChaosControl { private final MonitorContext ctx; // sends the request of the chaos action to the integration and returns its reply (null when it did not answer) private final Function<JsonObject, JsonObject> sender; + // the known rules of the catalog, or null when its catalog has none (loaded once, when a rule is first added) + private final Supplier<ChaosRecipes> recipeSource; + private ChaosRecipes recipes; + private boolean recipesLoaded; + private ChaosRecipeForm form; // set by the background task that sends the action, read when the screen is drawn private volatile Step step = Step.NONE; private Command command; @@ -80,12 +89,21 @@ final class ChaosControl { private boolean resultError; ChaosControl(MonitorContext ctx) { - this(ctx, null); + this(ctx, null, ChaosRecipes::load); } ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender) { + this(ctx, sender, () -> null); + } + + ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender, Supplier<ChaosRecipes> recipeSource) { this.ctx = ctx; this.sender = sender; + this.recipeSource = recipeSource; + } + + ChaosRecipeForm form() { + return form; } Step step() { @@ -93,7 +111,8 @@ final class ChaosControl { } boolean isTextInputActive() { - return step == Step.INPUT; + // 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; } boolean isConfirming() { @@ -113,6 +132,9 @@ final class ChaosControl { */ boolean handleKey(KeyEvent ke, IntegrationInfo info, ChaosInfo.Rule selected) { switch (step) { + case RECIPES -> { + return handleRecipeKey(ke); + } case INPUT -> { return handleInputKey(ke); } @@ -148,7 +170,15 @@ final class ChaosControl { name = info.name; if (ke.isChar('a')) { id = null; - openInput(Command.ADD, ""); + ChaosRecipes known = knownRules(); + if (known != null) { + // what can go wrong, in plain words, with the endpoints, routes and steps of the integration + command = Command.ADD; + form = new ChaosRecipeForm(known, info); + step = Step.RECIPES; + } else { + openInput(Command.ADD, ""); + } return true; } if (selected != null && selected.id != null && ke.isChar('e')) { @@ -173,6 +203,29 @@ final class ChaosControl { return false; } + private ChaosRecipes knownRules() { + if (!recipesLoaded) { + recipes = recipeSource.get(); + recipesLoaded = true; + } + return recipes; + } + + private boolean handleRecipeKey(KeyEvent ke) { + switch (form.handleKey(ke)) { + case CLOSED -> close(); + case TYPE_RULE -> openInput(Command.ADD, ""); + case RULE -> { + rule = form.rule(); + ask(Command.ADD); + } + default -> { + // the form used the key + } + } + return true; + } + private void openInput(Command what, String text) { command = what; input = new TextInputState(text != null ? text : ""); @@ -197,6 +250,10 @@ final class ChaosControl { /** Pastes into the rule being typed. */ boolean handlePaste(String text) { + if (step == Step.RECIPES) { + form.paste(text); + return true; + } if (step != Step.INPUT || text == null) { return false; } @@ -219,6 +276,10 @@ final class ChaosControl { if (step == Step.NONE) { return false; } + if (step == Step.RECIPES) { + // the form goes back to the list, the list closes + return handleRecipeKey(KeyEvent.ofKey(KeyCode.ESCAPE)); + } close(); return true; } @@ -226,6 +287,7 @@ final class ChaosControl { private void close() { step = Step.NONE; input = null; + form = null; } /** The request of the chaos action for the command, as {@code camel cmd chaos-*} sends it. */ @@ -333,6 +395,7 @@ final class ChaosControl { void render(Frame frame, Rect area) { switch (step) { + case RECIPES -> form.render(frame, area); case INPUT -> DialogHelper.renderInputDialog(frame, area, command == Command.CHANGE ? "Change chaos rule " + id : "Add a chaos rule to " + name, input, "endpoint=kafka:orders* error rate=0.1 until=5m"); @@ -408,6 +471,7 @@ final class ChaosControl { /** The keys of the screen, or of the open dialog, for the footer. */ void renderFooter(List<Span> spans, boolean hasRules) { switch (step) { + case RECIPES -> form.renderFooter(spans); case INPUT -> { TuiHelper.hint(spans, "Enter", "confirm"); 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/ChaosRecipeForm.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java new file mode 100644 index 000000000000..4459cae0814e --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java @@ -0,0 +1,334 @@ +/* + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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.input.TextInputState; +import dev.tamboui.widgets.paragraph.Paragraph; + +/** + * Adding a chaos rule from the known rules ({@link ChaosRecipes}): a list of what can go wrong, grouped, and for the + * chosen one a field per placeholder of its rule. A field with values to pick from (the endpoints, routes or steps of + * the integration) cycles through them with Left and Right, and can still be typed; the other fields start with the + * default of the recipe. The rule is shown as it is built, and Enter takes it to the confirmation of + * {@link ChaosControl}. The last entry of the list types the whole rule instead. + */ +final class ChaosRecipeForm { + + /** What the form gives back after a key. */ + enum Outcome { + // the key was used; nothing to do + NONE, + // Esc on the list: close + CLOSED, + // the last entry of the list: type the rule as one line + TYPE_RULE, + // the rule is complete: confirm it + RULE + } + + /** A field of the form: the placeholder, its value, and the values to pick from. */ + static final class Field { + final ChaosRecipes.Placeholder placeholder; + final TextInputState input; + final List<String> choices; + int choice = -1; + + Field(ChaosRecipes.Placeholder placeholder, List<String> choices) { + this.placeholder = placeholder; + this.choices = choices; + String start = placeholder.defaultValue() != null ? placeholder.defaultValue() : ""; + if (start.isEmpty() && !choices.isEmpty()) { + choice = 0; + start = choices.get(0); + } + this.input = new TextInputState(start); + input.moveCursorToEnd(); + } + + void cycle(int step) { + if (choices.isEmpty()) { + return; + } + choice = Math.floorMod(choice + step, choices.size()); + input.clear(); + input.insert(choices.get(choice)); + } + + String value() { + return input.text().strip(); + } + } + + private static final String TYPE_IT = "Write the rule yourself …"; + + private final List<ChaosRecipes.Recipe> recipes; + private final IntegrationInfo info; + // the index in the list (the recipes, then the entry that types the rule) + private int selected; + private boolean inForm; + private final List<Field> fields = new ArrayList<>(); + private int focus; + private String rule; + + ChaosRecipeForm(ChaosRecipes recipes, IntegrationInfo info) { + this.recipes = recipes.recipes(); + this.info = info; + } + + boolean inForm() { + return inForm; + } + + /** The rule of the form, with the values filled in. */ + String rule() { + return rule; + } + + ChaosRecipes.Recipe selectedRecipe() { + return selected < recipes.size() ? recipes.get(selected) : null; + } + + List<Field> fields() { + return fields; + } + + Outcome handleKey(KeyEvent ke) { + return inForm ? handleFormKey(ke) : handleListKey(ke); + } + + private Outcome handleListKey(KeyEvent ke) { + if (ke.isCancel()) { + return Outcome.CLOSED; + } + if (ke.isUp()) { + selected = Math.max(0, selected - 1); + } else if (ke.isDown()) { + selected = Math.min(recipes.size(), selected + 1); + } else if (ke.isConfirm() || ke.isRight()) { + if (selected == recipes.size()) { + return Outcome.TYPE_RULE; + } + openForm(recipes.get(selected)); + } + return Outcome.NONE; + } + + private void openForm(ChaosRecipes.Recipe recipe) { + fields.clear(); + for (ChaosRecipes.Placeholder p : recipe.placeholders()) { + fields.add(new Field(p, ChaosRecipes.candidates(p, info))); + } + focus = 0; + inForm = true; + } + + private Outcome handleFormKey(KeyEvent ke) { + if (ke.isCancel()) { + // back to the list + inForm = false; + return Outcome.NONE; + } + Field field = fields.isEmpty() ? null : fields.get(focus); + if (ke.isUp()) { + focus = Math.max(0, focus - 1); + } else if (ke.isDown() || ke.isKey(KeyCode.TAB)) { + focus = Math.min(fields.size() - 1, focus + 1); + } else if (field != null && !field.choices.isEmpty() && (ke.isLeft() || ke.isRight())) { + field.cycle(ke.isRight() ? 1 : -1); + } else if (ke.isConfirm()) { + String built = build(); + if (missing() == null) { + rule = built; + return Outcome.RULE; + } + } else if (field != null) { + FormHelper.handleTextInput(ke, field.input); + field.choice = field.choices.indexOf(field.value()); + } + return Outcome.NONE; + } + + /** Pastes into the field in focus. */ + void paste(String text) { + if (inForm && !fields.isEmpty() && text != null) { + fields.get(focus).input.insert(text.replace('\n', ' ').replace('\r', ' ')); + } + } + + /** The rule with the values of the fields so far. */ + String build() { + ChaosRecipes.Recipe recipe = selectedRecipe(); + if (recipe == null) { + return ""; + } + Map<String, String> values = new LinkedHashMap<>(); + fields.forEach(f -> values.put(f.placeholder.name(), f.value())); + return recipe.fill(values); + } + + /** The first field without a value, or null when the rule is complete. */ + String missing() { + for (Field f : fields) { + if (f.value().isEmpty()) { + return f.placeholder.name(); + } + } + return null; + } + + // ---- rendering ---- + + void render(Frame frame, Rect area) { + int width = Math.min(area.width() - 4, 150); + int height = Math.min(area.height() - 2, 30); + 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(" Add a chaos rule to " + info.name + " ").build(); + frame.renderWidget(outer, popup); + Rect inner = outer.inner(popup); + List<Rect> cols = Layout.horizontal() + .constraints(Constraint.length(Math.min(46, inner.width() / 3)), Constraint.fill()) + .split(inner); + renderList(frame, cols.get(0)); + renderRight(frame, cols.get(1)); + } + + private void renderList(Frame frame, Rect area) { + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(Span.styled(" What should go wrong?", Style.EMPTY.bold()))); + String group = null; + int selectedLine = 0; + for (int i = 0; i <= recipes.size(); i++) { + String title; + if (i < recipes.size()) { + ChaosRecipes.Recipe r = recipes.get(i); + if (!r.group().equals(group)) { + group = r.group(); + lines.add(Line.from(Span.styled(" " + group, Theme.muted()))); + } + title = r.title(); + } else { + title = TYPE_IT; + } + boolean sel = i == selected; + if (sel) { + selectedLine = lines.size(); + } + Style style = sel ? (inForm ? Style.EMPTY.bold() : Theme.selectionBg().bold()) : Style.EMPTY; + lines.add(Line.from(Span.styled((sel ? " > " : " ") + title, style))); + } + // keep the selected recipe 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 renderRight(Frame frame, Rect area) { + ChaosRecipes.Recipe recipe = selectedRecipe(); + Rect pad = new Rect(area.x() + 1, area.y(), Math.max(1, area.width() - 2), area.height()); + if (recipe == null) { + frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from( + Line.from(Span.styled(" Type the rule as one line, such as", Style.EMPTY)), + Line.from(Span.styled(" endpoint=kafka:orders* error rate=0.1 until=5m", Theme.muted())))) + .build(), pad); + return; + } + List<Line> top = new ArrayList<>(); + top.add(Line.from(Span.styled(recipe.title(), Theme.warning().bold()))); + top.add(Line.from(Span.styled(recipe.description() != null ? recipe.description() : "", Theme.muted()))); + top.add(Line.empty()); + int topHeight = 4 + (recipe.description() != null ? recipe.description().length() / Math.max(1, pad.width()) : 0); + frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(top)).build(), + new Rect(pad.x(), pad.y(), pad.width(), Math.min(topHeight, pad.height()))); + int y = pad.y() + topHeight; + if (!inForm) { + frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from( + Line.from(Span.styled(recipe.rule(), Theme.muted())))).build(), + new Rect(pad.x(), y, pad.width(), Math.max(1, pad.bottom() - y))); + return; + } + int labelWidth = 12; + for (int i = 0; i < fields.size() && y < pad.bottom() - 4; i++, y++) { + Field f = fields.get(i); + boolean focused = i == focus; + frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.styled( + String.format("%-" + labelWidth + "s", f.placeholder.name()), + focused ? Theme.warning().bold() : Theme.muted())))).build(), + new Rect(pad.x(), y, labelWidth, 1)); + String hint = f.choices.isEmpty() + ? "" + : " ◂ ▸ " + (f.choice >= 0 ? (f.choice + 1) + " of " + f.choices.size() : f.choices.size() + " to pick"); + int fieldWidth = Math.max(10, pad.width() - labelWidth - hint.length() - 1); + FormHelper.renderTextField(frame, new Rect(pad.x() + labelWidth, y, fieldWidth, 1), f.input, focused, + f.placeholder.example()); + if (!hint.isEmpty()) { + frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.styled(hint, Theme.muted())))).build(), + new Rect(pad.x() + labelWidth + fieldWidth, y, hint.length(), 1)); + } + } + Field focusedField = fields.isEmpty() ? null : fields.get(focus); + List<Line> bottom = new ArrayList<>(); + if (focusedField != null && focusedField.placeholder.description() != null) { + bottom.add(Line.from(Span.styled(focusedField.placeholder.description(), Theme.muted()))); + } + bottom.add(Line.empty()); + bottom.add(Line.from(Span.styled(build(), Theme.info()))); + String missing = missing(); + bottom.add(missing == null + ? Line.from(Span.styled("Enter to add it (asks to confirm)", Theme.success())) + : Line.from(Span.styled(missing + " needs a value", Theme.warning()))); + int bottomY = y + 1; + frame.renderWidget(Paragraph.builder().overflow(Overflow.WRAP_WORD).text(Text.from(bottom)).build(), + new Rect(pad.x(), bottomY, pad.width(), Math.max(1, pad.bottom() - bottomY))); + } + + /** The keys of the form, for the footer. */ + void renderFooter(List<Span> spans) { + if (inForm) { + TuiHelper.hint(spans, "↑↓", "field"); + TuiHelper.hint(spans, "←→", "pick"); + TuiHelper.hint(spans, "Enter", "add"); + TuiHelper.hint(spans, "Esc", "back"); + } else { + TuiHelper.hint(spans, "↑↓", "choose"); + TuiHelper.hint(spans, "Enter", "fill in"); + 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/ChaosRecipes.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java new file mode 100644 index 000000000000..624d1cc883d1 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java @@ -0,0 +1,204 @@ +/* + * 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.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * The known rules of camel-chaos (the recipes of the chaos model of the catalog, {@code CamelCatalog.chaosModelJSon()}, + * CAMEL-26689): what goes wrong in plain words, each a one-line rule with {@code ${name}} placeholders, and where to + * pick the value of a placeholder from in the integration (the endpoints its routes send to, the endpoints it polls, + * its routes, its steps, or listed values). + */ +final class ChaosRecipes { + + /** A placeholder of a recipe. */ + record Placeholder(String name, String type, String description, String defaultValue, String example, String pickFrom, + List<String> schemes, List<String> values) { + } + + /** A known rule. */ + record Recipe(String id, String group, String title, String description, String rule, List<Placeholder> placeholders) { + + /** The rule with the values of the placeholders; a placeholder without a value stays {@code ${name}}. */ + String fill(Map<String, String> values) { + String answer = rule; + for (Placeholder p : placeholders) { + String value = values.get(p.name()); + if (value != null && !value.isBlank()) { + answer = answer.replace("${" + p.name() + "}", value.strip()); + } + } + return answer; + } + } + + private final List<Recipe> recipes; + + ChaosRecipes(List<Recipe> recipes) { + this.recipes = List.copyOf(recipes); + } + + List<Recipe> recipes() { + return recipes; + } + + /** + * The recipes of the catalog of the Camel CLI, or null when its catalog has no chaos model (a Camel version before + * it). + */ + static ChaosRecipes load() { + try { + String json = new DefaultCamelCatalog().chaosModelJSon(); + return json != null ? parse(json) : null; + } catch (RuntimeException | LinkageError e) { + // an older catalog: the rule is typed as one line + return null; + } + } + + /** The recipes of the chaos model ({@code {"chaos": {"recipes": [...]}}}). */ + static ChaosRecipes parse(String json) { + JsonObject root; + try { + root = (JsonObject) Jsoner.deserialize(json); + } catch (Exception e) { + return null; + } + if (!(root.get("chaos") instanceof JsonObject chaos) || !(chaos.get("recipes") instanceof JsonArray array)) { + return null; + } + List<Recipe> answer = new ArrayList<>(); + for (Object o : array) { + if (!(o instanceof JsonObject r) || r.getString("rule") == null) { + continue; + } + List<Placeholder> placeholders = new ArrayList<>(); + if (r.get("placeholders") instanceof JsonArray ps) { + for (Object po : ps) { + if (po instanceof JsonObject p) { + JsonObject pick = p.get("pick") instanceof JsonObject pj ? pj : null; + placeholders.add(new Placeholder( + p.getString("name"), p.getString("type"), p.getString("description"), + p.getString("default"), p.getString("example"), + pick != null ? pick.getString("from") : null, + strings(pick != null ? pick.get("schemes") : null), + strings(pick != null ? pick.get("values") : null))); + } + } + } + answer.add(new Recipe( + r.getString("id"), r.getStringOrDefault("group", ""), r.getString("title"), + r.getString("description"), r.getString("rule"), placeholders)); + } + return answer.isEmpty() ? null : new ChaosRecipes(answer); + } + + private static List<String> strings(Object value) { + List<String> answer = new ArrayList<>(); + if (value instanceof List<?> list) { + list.forEach(v -> answer.add(String.valueOf(v))); + } + return answer; + } + + /** + * The values a placeholder can be picked from in the integration: the endpoints its routes send to (of the schemes + * of the placeholder, when it names some), the endpoints it consumes with a scheduled poll, its routes, its steps + * (the ones with an id of their own first), or the listed values. Empty when the value is typed. + */ + static List<String> candidates(Placeholder p, IntegrationInfo info) { + Set<String> answer = new LinkedHashSet<>(); + if (p.pickFrom() == null) { + return List.of(); + } + switch (p.pickFrom()) { + case "values" -> answer.addAll(p.values()); + case "producers" -> { + for (ProducerInfo producer : info.producers) { + addUri(answer, producer.uri, p.schemes()); + } + for (EndpointInfo endpoint : info.endpoints) { + if ("out".equalsIgnoreCase(endpoint.direction)) { + addUri(answer, endpoint.uri, p.schemes()); + } + } + } + case "pollingConsumers" -> { + for (ConsumerInfo consumer : info.consumers) { + if (consumer.scheduled) { + addUri(answer, consumer.uri, p.schemes()); + } + } + } + case "routes" -> info.routes.forEach(r -> { + if (r.routeId != null) { + answer.add(r.routeId); + } + }); + case "steps" -> { + List<String> named = new ArrayList<>(); + List<String> generated = new ArrayList<>(); + for (RouteInfo route : info.routes) { + for (ProcessorInfo step : route.processors) { + if (step.id != null) { + (isGeneratedId(step.id) ? generated : named).add(step.id); + } + } + } + answer.addAll(named); + answer.addAll(generated); + } + default -> { + // typed (such as a header name) + } + } + return List.copyOf(answer); + } + + // an id Camel made up, such as to1 or log3 + private static boolean isGeneratedId(String id) { + int i = id.length(); + while (i > 0 && Character.isDigit(id.charAt(i - 1))) { + i--; + } + return i < id.length() && i > 0 && id.substring(0, i).chars().allMatch(Character::isLowerCase); + } + + /** Adds the URI without its query (the rule matches the endpoint with or without it), of one of the schemes. */ + private static void addUri(Set<String> answer, String uri, List<String> schemes) { + if (uri == null || uri.isBlank()) { + return; + } + int q = uri.indexOf('?'); + String base = q > 0 ? uri.substring(0, q) : uri; + int colon = base.indexOf(':'); + String scheme = colon > 0 ? base.substring(0, colon) : base; + if (schemes.isEmpty() || schemes.contains(scheme)) { + answer.add(base); + } + } +} 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 a8effd4f8136..456b4aa87446 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 @@ -55,7 +55,13 @@ error handler (HAND) and the redeliveries (REDEL). ## Keys -- **a** — Add a rule, typed as one line, such as `endpoint=kafka:orders* error rate=0.1 until=5m` +- **a** — Add a rule: pick what should go wrong from the known rules of + camel-chaos (such as "Downstream returns an error", "Did the work, then + failed", "The route flaps"), then fill in its values: the endpoints, + routes and steps of the integration are picked with Left and Right, and + the rule is shown as it is built. The last entry, "Write the rule + yourself", types it as one line, such as + `endpoint=kafka:orders* error rate=0.1 until=5m` - **e** — Change the selected rule: the line starts with the rule as it is - **x** — Remove the selected rule - **c** — Clear: remove all rules (the report keeps what they did) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeFormTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeFormTest.java new file mode 100644 index 000000000000..3df8981db10e --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeFormTest.java @@ -0,0 +1,174 @@ +/* + * 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.atomic.AtomicReference; + +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.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; + +/** + * Adding a chaos rule from the known rules ({@link ChaosRecipeForm}), through the keys of the chaos screen. + */ +class ChaosRecipeFormTest { + + private final List<JsonObject> requests = new ArrayList<>(); + private ChaosTab tab; + + @BeforeEach + void setUp() throws Exception { + Theme.resetForTesting(); + IntegrationInfo info = ChaosRecipesTest.chaosdemo(); + 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"; + ctx.confirmActions = true; + ChaosRecipes recipes = ChaosRecipes.parse(ChaosRecipesTest.MODEL); + tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> { + requests.add(request); + JsonObject reply = new JsonObject(); + reply.put("id", "r1"); + return reply; + }, () -> recipes)); + } + + private void key(char ch) { + tab.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE)); + } + + private void key(KeyCode code) { + tab.handleKeyEvent(KeyEvent.ofKey(code, KeyModifiers.NONE)); + } + + private String screen() { + return TuiTestHelper.renderToString(tab, 160, 40); + } + + @Test + void theKnownRulesByWhatGoesWrong() { + key('a'); + assertThat(tab.control().step()).isEqualTo(ChaosControl.Step.RECIPES); + assertThat(tab.isTextInputActive()).isTrue(); + assertThat(screen()).contains("Add a chaos rule to chaosdemo").contains("What should go wrong?") + .contains("Downstream").contains("Returns an error (HTTP status)").contains("Consumers and routes") + .contains("The route stalls").contains("Write the rule yourself") + // the rule of the recipe in focus + .contains("endpoint=${endpoint} error status=${status} rate=${rate}"); + } + + @Test + void aRecipeFilledWithTheEndpointOfTheIntegrationAndItsDefaults() { + key('a'); + key(KeyCode.ENTER); + assertThat(tab.control().form().inForm()).isTrue(); + // the HTTP endpoint the route calls is picked, the status and the rate are the defaults + assertThat(screen()).contains("endpoint=http://localhost:8765/stock error status=503 rate=0.25") + .contains("Enter to add it"); + + key(KeyCode.ENTER); + assertThat(tab.isConfirming()).isTrue(); + assertThat(screen()).contains("endpoint=http://localhost:8765/stock error status=503 rate=0.25"); + key('y'); + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getString("command")).isEqualTo("add"); + assertThat(requests.get(0).getString("rule")) + .isEqualTo("endpoint=http://localhost:8765/stock error status=503 rate=0.25"); + } + + @Test + void leftAndRightPickAnotherValue() { + key('a'); + key(KeyCode.DOWN); + key(KeyCode.ENTER); + // route: orders, then inventory + assertThat(screen()).contains("route=orders stall duration=20s").contains("1 of 2"); + key(KeyCode.RIGHT); + assertThat(screen()).contains("route=inventory stall duration=20s").contains("2 of 2"); + // a value is typed too: the duration + key(KeyCode.DOWN); + for (int i = 0; i < 3; i++) { + key(KeyCode.BACKSPACE); + } + key('5'); + key('s'); + key(KeyCode.ENTER); + key('y'); + assertThat(requests.get(0).getString("rule")).isEqualTo("route=inventory stall duration=5s"); + } + + @Test + void aValueThatIsTypedMustBeThereBeforeEnter() { + key('a'); + for (int i = 0; i < 3; i++) { + key(KeyCode.DOWN); + } + key(KeyCode.ENTER); + assertThat(screen()).contains("header needs a value"); + key(KeyCode.ENTER); + assertThat(tab.isConfirming()).isFalse(); + key(KeyCode.DOWN); + for (char ch : "X-Trace-Id".toCharArray()) { + key(ch); + } + key(KeyCode.ENTER); + assertThat(tab.isConfirming()).isTrue(); + assertThat(screen()).contains("corrupt mode=remove-header header=X-Trace-Id"); + } + + @Test + void theLastEntryTypesTheRuleAndEscGoesBack() { + key('a'); + key(KeyCode.ENTER); + // Esc: from the form back to the list, then closed + assertThat(tab.handleEscape()).isTrue(); + assertThat(tab.control().form().inForm()).isFalse(); + assertThat(tab.handleEscape()).isTrue(); + assertThat(tab.control().isOpen()).isFalse(); + + key('a'); + for (int i = 0; i < 10; i++) { + key(KeyCode.DOWN); + } + key(KeyCode.ENTER); + assertThat(tab.control().step()).isEqualTo(ChaosControl.Step.INPUT); + assertThat(requests).isEmpty(); + } + + @Test + void theFooterSaysWhatTheKeysDo() { + key('a'); + List<Span> footer = new ArrayList<>(); + tab.renderFooter(footer); + assertThat(footer.stream().map(Span::content).reduce("", String::concat)).contains("choose").contains("fill in"); + key(KeyCode.ENTER); + footer.clear(); + tab.renderFooter(footer); + assertThat(footer.stream().map(Span::content).reduce("", String::concat)).contains("pick").contains("add"); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java new file mode 100644 index 000000000000..6a141232fa93 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java @@ -0,0 +1,147 @@ +/* + * 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.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The known rules of the chaos model ({@link ChaosRecipes}): parsed from the catalog, filled in, and the values to pick + * from in an integration. + */ +class ChaosRecipesTest { + + // the shape of the recipes of the chaos model of the catalog + static final String MODEL = """ + {"chaos": {"recipes": [ + {"id": "http-error", "group": "Downstream", "title": "Returns an error (HTTP status)", + "description": "An HTTP call fails with a status", + "rule": "endpoint=${endpoint} error status=${status} rate=${rate}", + "placeholders": [ + {"name": "endpoint", "type": "endpoint", "description": "The HTTP endpoint the route calls", + "example": "https://inventory/stock", "pick": {"from": "producers", "schemes": ["http", "https"]}}, + {"name": "status", "type": "integer", "description": "The HTTP status", "default": "503", "example": "503"}, + {"name": "rate", "type": "rate", "description": "How often", "default": "0.25", "example": "0.25"}]}, + {"id": "stall", "group": "Consumers and routes", "title": "The route stalls", + "description": "The route is suspended for a while", + "rule": "route=${route} stall duration=${duration}", + "placeholders": [ + {"name": "route", "type": "route", "description": "The route", "example": "orders", + "pick": {"from": "routes"}}, + {"name": "duration", "type": "duration", "description": "How long", "default": "20s", "example": "20s"}]}, + {"id": "corrupt-entry", "group": "Messages", "title": "Arrives corrupt", + "rule": "route=${route} corrupt mode=${mode}", + "placeholders": [ + {"name": "route", "type": "route", "example": "orders", "pick": {"from": "routes"}}, + {"name": "mode", "type": "enum", "default": "headers", "example": "headers", + "pick": {"from": "values", "values": ["headers", "charset", "empty"]}}]}, + {"id": "missing-header", "group": "Messages", "title": "Is sent without a header", + "rule": "endpoint=${endpoint} corrupt mode=remove-header header=${header}", + "placeholders": [ + {"name": "endpoint", "type": "endpoint", "example": "kafka:orders", "pick": {"from": "producers"}}, + {"name": "header", "type": "string", "example": "X-Trace-Id", "pick": {"from": "headers"}}]}, + {"id": "step-error", "group": "Error handling", "title": "A step fails", + "rule": "step=${step} error", + "placeholders": [{"name": "step", "type": "step", "example": "enrich", "pick": {"from": "steps"}}]}, + {"id": "poll-error", "group": "Consumers and routes", "title": "Polls fail", + "rule": "endpoint=${endpoint} error at=poll", + "placeholders": [{"name": "endpoint", "type": "endpoint", "example": "file:inbox", + "pick": {"from": "pollingConsumers"}}]} + ]}} + """; + + static IntegrationInfo chaosdemo() { + IntegrationInfo info = new IntegrationInfo(); + info.pid = "1234"; + info.name = "chaosdemo"; + for (String uri : List.of("http://localhost:8765/stock?bridgeEndpoint=true", "seda://shipping", "direct://inventory")) { + ProducerInfo producer = new ProducerInfo(); + producer.uri = uri; + info.producers.add(producer); + } + ConsumerInfo timer = new ConsumerInfo(); + timer.uri = "timer://orders?period=500"; + ConsumerInfo files = new ConsumerInfo(); + files.uri = "file://inbox?delay=1000"; + files.scheduled = true; + info.consumers.add(timer); + info.consumers.add(files); + for (String id : List.of("orders", "inventory")) { + RouteInfo route = new RouteInfo(); + route.routeId = id; + info.routes.add(route); + } + for (String step : List.of("to1", "callStock", "log2", "enrich")) { + ProcessorInfo p = new ProcessorInfo(); + p.id = step; + info.routes.get(0).processors.add(p); + } + return info; + } + + @Test + void theRecipesOfTheModel() { + ChaosRecipes recipes = ChaosRecipes.parse(MODEL); + assertThat(recipes.recipes()).hasSize(6); + ChaosRecipes.Recipe http = recipes.recipes().get(0); + assertThat(http.title()).isEqualTo("Returns an error (HTTP status)"); + assertThat(http.placeholders()).extracting(ChaosRecipes.Placeholder::name).containsExactly("endpoint", "status", + "rate"); + assertThat(http.placeholders().get(0).schemes()).containsExactly("http", "https"); + assertThat(http.fill(Map.of("endpoint", "http://localhost:8765/stock", "status", "503", "rate", "0.25"))) + .isEqualTo("endpoint=http://localhost:8765/stock error status=503 rate=0.25"); + // a placeholder without a value stays, so the form can say what is missing + assertThat(http.fill(Map.of("status", "503"))).startsWith("endpoint=${endpoint} error status=503"); + + assertThat(ChaosRecipes.parse("{\"other\": 1}")).isNull(); + assertThat(ChaosRecipes.parse("not json")).isNull(); + } + + @Test + void theValuesToPickFromInTheIntegration() { + ChaosRecipes recipes = ChaosRecipes.parse(MODEL); + IntegrationInfo info = chaosdemo(); + List<ChaosRecipes.Recipe> r = recipes.recipes(); + // the producers of the schemes of the placeholder, without their query + assertThat(ChaosRecipes.candidates(r.get(0).placeholders().get(0), info)) + .containsExactly("http://localhost:8765/stock"); + assertThat(ChaosRecipes.candidates(r.get(3).placeholders().get(0), info)) + .containsExactly("http://localhost:8765/stock", "seda://shipping", "direct://inventory"); + assertThat(ChaosRecipes.candidates(r.get(1).placeholders().get(0), info)).containsExactly("orders", "inventory"); + assertThat(ChaosRecipes.candidates(r.get(2).placeholders().get(1), info)).containsExactly("headers", "charset", + "empty"); + // the steps with an id of their own first + assertThat(ChaosRecipes.candidates(r.get(4).placeholders().get(0), info)) + .containsExactly("callStock", "enrich", "to1", "log2"); + // the consumers that poll on a schedule + assertThat(ChaosRecipes.candidates(r.get(5).placeholders().get(0), info)).containsExactly("file://inbox"); + // a header name is typed + assertThat(ChaosRecipes.candidates(r.get(3).placeholders().get(1), info)).isEmpty(); + } + + @Test + void theCatalogOfTheCliHasTheRecipes() { + ChaosRecipes recipes = ChaosRecipes.load(); + assertThat(recipes).isNotNull(); + assertThat(recipes.recipes()).extracting(ChaosRecipes.Recipe::id).contains("http-error", "failed-after-success", + "flap"); + } +}
