This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26689-chaos-model in repository https://gitbox.apache.org/repos/asf/camel.git
commit 92d30481b8c41ed3fdf741bf71370cd561f3a268 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Nov 6 14:31:09 2026 +0100 CAMEL-26689: camel-chaos - a model of the chaos rules in camel-catalog: targets, hooks, faults, parameters and known rules Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/chaos-model.json | 1078 ++++++++++++++++++++ .../org/apache/camel/catalog/docs/chaos.adoc | 23 + .../org/apache/camel/catalog/CamelCatalog.java | 10 + .../apache/camel/catalog/DefaultCamelCatalog.java | 5 + .../org/apache/camel/catalog/CamelCatalogTest.java | 11 + .../org/apache/camel/chaos/chaos-model.json | 1078 ++++++++++++++++++++ components/camel-chaos/src/main/docs/chaos.adoc | 23 + .../apache/camel/component/chaos/ChaosModel.java | 602 +++++++++++ .../camel/component/chaos/ChaosRuleParser.java | 61 +- .../camel/component/chaos/ChaosModelTest.java | 194 ++++ .../modules/ROOT/pages/camel-catalog.adoc | 9 + .../camel/maven/packaging/PrepareCatalogMojo.java | 17 + 12 files changed, 3094 insertions(+), 17 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json new file mode 100644 index 000000000000..7505fb834508 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json @@ -0,0 +1,1078 @@ +{ + "chaos": { + "description": "The chaos rules of camel-chaos: a rule is one line, the target, the fault and its parameters as name=value, then the options (such as at, rate, from, until) and optionally an expectation", + "syntax": "<target>=<pattern> <fault> [<parameter>=<value> ...] [<option>=<value> ...]", + "targets": [ + { + "name": "route", + "description": "A route id, with * for any characters; one route id (no pattern) for stall and lifecycle", + "pattern": true, + "pick": "routes" + }, + { + "name": "endpoint", + "description": "An endpoint URI pattern, with * for any characters; matched with or without the query parameters, and the \/\/ after the scheme does not count (kafka:orders* matches kafka:\/\/orders?brokers=b1)", + "pattern": true, + "pick": "endpoints" + }, + { + "name": "component", + "description": "The scheme of the endpoint URIs, with * for any characters (no ':')", + "pattern": true, + "pick": "components" + }, + { + "name": "step", + "description": "A step id (to1, or the id set with id(\"enrich\")), with * for any characters", + "pattern": true, + "pick": "steps" + } + ], + "hooks": [ + { + "name": "send", + "description": "The sends to a matching endpoint (to, toD, wireTap, enrich, recipientList, a ProducerTemplate...), before the producer is called, inside the error handler", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop", + "corrupt" + ], + "onlyWithAt": false + }, + { + "name": "reply", + "description": "The reply of such a send, after the producer succeeded: its side effect happened (only with at=reply)", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "corrupt" + ], + "onlyWithAt": true + }, + { + "name": "dlq", + "description": "The send of the dead letter channel to a matching endpoint (only with at=dlq)", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop", + "corrupt" + ], + "onlyWithAt": true + }, + { + "name": "poll", + "description": "The polls of the scheduled poll consumers of the routes", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop" + ], + "onlyWithAt": false + }, + { + "name": "entry", + "description": "Each exchange that enters a matching route, before the first step", + "targets": [ + "route" + ], + "faults": [ + "corrupt" + ], + "onlyWithAt": false + }, + { + "name": "step", + "description": "Each matching step of a route, right before its processor is called", + "targets": [ + "step" + ], + "faults": [ + "error", + "delay" + ], + "onlyWithAt": false + } + ], + "faults": [ + { + "name": "error", + "description": "Fails the send, the poll or the step with an exception (a ChaosException without exception=), or the failure of an HTTP component for status=", + "targets": [ + "component", + "endpoint", + "step" + ], + "hooks": [ + "send", + "reply", + "dlq", + "poll", + "step" + ], + "parameters": [ + { + "name": "after", + "type": "duration", + "required": false, + "description": "Waits that long first, then fails, as a call that times out; the producer is not called", + "example": "5s", + "notAt": [ + "reply" + ] + }, + { + "name": "body", + "type": "string", + "required": false, + "description": "The body of the HTTP response (needs status=)", + "example": "no stock" + }, + { + "name": "exception", + "type": "class", + "required": false, + "description": "The class of the exception: a public Throwable with a public constructor taking a String or none (not an InterruptedException)", + "example": "java.net.ConnectException" + }, + { + "name": "header.<name>", + "type": "string", + "required": false, + "description": "A header of the HTTP response, such as header.Retry-After=5 (needs status=)", + "example": "5", + "prefix": "header." + }, + { + "name": "message", + "type": "string", + "required": false, + "description": "The message of the exception (the class needs a constructor taking a String)", + "example": "broker down" + }, + { + "name": "status", + "type": "integer", + "required": false, + "description": "The failure of an HTTP component for this status (not with exception= or message=)", + "example": "503", + "min": 300, + "max": 599, + "schemes": [ + "http", + "https", + "netty-http", + "undertow", + "vertx-http" + ] + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "delay", + "description": "Waits before the send, the poll or the step, as for a slow endpoint", + "targets": [ + "component", + "endpoint", + "step" + ], + "hooks": [ + "send", + "dlq", + "poll", + "step" + ], + "parameters": [ + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long to wait, or a range such as 100ms..2s for a draw between the two times (from the seeded sequence of the rule)", + "example": "500ms", + "range": true + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "drop", + "description": "A send that reports success without calling the producer, or a poll that is skipped", + "targets": [ + "component", + "endpoint" + ], + "hooks": [ + "send", + "dlq", + "poll" + ], + "parameters": [ + + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "corrupt", + "description": "Changes the message: its headers, the charset of its body, or its body", + "targets": [ + "component", + "endpoint", + "route" + ], + "hooks": [ + "send", + "reply", + "dlq", + "entry" + ], + "parameters": [ + { + "name": "header", + "type": "string", + "required": false, + "description": "The headers mode=remove-header removes: a name or a pattern with *, ignoring case (without it every header that is not Camel*)", + "example": "X-Trace-*" + }, + { + "name": "mode", + "type": "enum", + "required": true, + "description": "headers: the headers that are not Camel* get random values; charset: a text body becomes UTF-16 bytes; truncate: a String, byte[] or stream body ends after half of it; empty: the body becomes empty of its kind (or null); remove-header: removes headers", + "example": "headers", + "enum": [ + "headers", + "charset", + "truncate", + "empty", + "remove-header" + ] + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "lifecycle", + "description": "Suspends, stops or removes the route of a route=<id> target, and brings it back after the duration; repeat= takes it down more than once", + "targets": [ + "route" + ], + "hooks": [ + + ], + "parameters": [ + { + "name": "action", + "type": "enum", + "required": true, + "description": "What to do to the route", + "example": "stop", + "enum": [ + "suspend", + "stop", + "remove" + ] + }, + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long the route stays down (at most camel.chaos.maxDuration)", + "example": "20s" + }, + { + "name": "every", + "type": "duration", + "required": false, + "description": "The time from one time the route goes down to the next, longer than duration=", + "example": "1m" + }, + { + "name": "repeat", + "type": "integer", + "required": false, + "description": "How many times to take the route down (needs every= when more than 1)", + "example": "3", + "min": 1, + "max": 100 + } + ], + "options": [ + "expect", + "from", + "within" + ] + }, + { + "name": "stall", + "description": "Short for lifecycle action=suspend: the route stalls for the duration", + "targets": [ + "route" + ], + "hooks": [ + + ], + "shortFor": "lifecycle action=suspend", + "parameters": [ + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long the route stays down (at most camel.chaos.maxDuration)", + "example": "20s" + }, + { + "name": "every", + "type": "duration", + "required": false, + "description": "The time from one time the route goes down to the next, longer than duration=", + "example": "1m" + }, + { + "name": "repeat", + "type": "integer", + "required": false, + "description": "How many times to take the route down (needs every= when more than 1)", + "example": "3", + "min": 1, + "max": 100 + } + ], + "options": [ + "expect", + "from", + "within" + ] + } + ], + "options": [ + { + "name": "at", + "type": "enum", + "required": false, + "description": "Narrows the rule to one hook of its target (reply and dlq are reached only with at=)", + "example": "send", + "enum": [ + "send", + "reply", + "dlq", + "poll", + "entry", + "step" + ] + }, + { + "name": "expect", + "type": "expectation", + "required": false, + "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed) or <route>:<kind><op><count>", + "example": "orders:handled<=0" + }, + { + "name": "from", + "type": "duration", + "required": false, + "description": "When the rule starts after it is added, such as after the warm-up (+30s or 30s)", + "example": "+30s" + }, + { + "name": "rate", + "type": "rate", + "required": false, + "description": "How often the rule fires: a probability above 0 and at most 1, every Nth match, or bursts (every match during the first part of each period); without it every match fires", + "example": "0.25", + "forms": [ + "0.25", + "every:4", + "burst:5s\/30s" + ] + }, + { + "name": "until", + "type": "countOrDuration", + "required": false, + "description": "When the rule ends: a number of matches or a time from its start (at most camel.chaos.maxDuration, its default)", + "example": "10m" + }, + { + "name": "within", + "type": "duration", + "required": false, + "description": "The time of the expectation (by default until the rule ends)", + "example": "2m" + } + ], + "expectation": { + "kinds": [ + { + "name": "completed", + "description": "The exchanges completed in the route" + }, + { + "name": "handled", + "description": "The exchanges handled by the error handler in the route" + }, + { + "name": "failed", + "description": "The exchanges failed in the route" + }, + { + "name": "redeliveries", + "description": "The redeliveries in the route" + } + ], + "ops": [ + { + "name": ">=", + "description": "at least" + }, + { + "name": "<=", + "description": "at most" + }, + { + "name": "=", + "description": "exactly" + } + ] + }, + "limits": { + "maxLength": 2000, + "maxRepeat": 100, + "maxDuration": "camel.chaos.maxDuration (default 10m): the longest until, delay, after, from, burst period and stall or lifecycle duration", + "time": "a number with a unit (ms, s, m, h, d), also combined as 1m30s; a number alone is milliseconds", + "quoting": "a value with spaces or ; is quoted with \" or ' (\\\", \\' and \\\\ inside quotes)" + }, + "recipes": [ + { + "id": "http-error", + "group": "Downstream", + "title": "Returns an error (HTTP status)", + "description": "An HTTP call fails with a status, as for a service that is down or overloaded: tests onException, redelivery and the dead letter channel", + "rule": "endpoint=${endpoint} error status=${status} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The HTTP endpoint the route calls", + "example": "https:\/\/inventory\/stock", + "pick": { + "from": "producers", + "schemes": [ + "http", + "https", + "netty-http", + "undertow", + "vertx-http" + ] + } + }, + { + "name": "status", + "type": "integer", + "description": "The HTTP status", + "default": "503", + "example": "503" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "connection-refused", + "group": "Downstream", + "title": "Refuses the connection", + "description": "The send fails as when the other side is not there", + "rule": "endpoint=${endpoint} error exception=java.net.ConnectException rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "slow", + "group": "Downstream", + "title": "Is slow", + "description": "Each send waits a different time within a range, as a slow service does", + "rule": "endpoint=${endpoint} delay duration=${duration} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "duration", + "type": "duration", + "description": "The time, or a range of times", + "default": "200ms..3s", + "example": "200ms..3s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "timeout", + "group": "Downstream", + "title": "Times out", + "description": "The send waits, then fails without calling the producer, as a call that times out", + "rule": "endpoint=${endpoint} error after=${after} exception=java.net.SocketTimeoutException rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "after", + "type": "duration", + "description": "How long before it times out", + "default": "5s", + "example": "5s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.1", + "example": "0.1" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "failed-after-success", + "group": "Downstream", + "title": "Did the work, then failed", + "description": "The send succeeds and then fails, as a reply that is lost: a redelivery sends it again, so this tests that a duplicate does no harm", + "rule": "endpoint=${endpoint} error at=reply until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "until", + "type": "countOrDuration", + "description": "How many sends fail", + "default": "1", + "example": "1" + } + ] + }, + { + "id": "outage", + "group": "Downstream", + "title": "Is down for a while", + "description": "Every send fails during the first part of each period, as outage windows", + "rule": "endpoint=${endpoint} error rate=burst:${outage}\/${period} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "outage", + "type": "duration", + "description": "How long each outage lasts", + "default": "10s", + "example": "10s" + }, + { + "name": "period", + "type": "duration", + "description": "The time from one outage to the next", + "default": "1m", + "example": "1m" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "corrupt-entry", + "group": "Messages", + "title": "Arrives corrupt", + "description": "The messages that enter the route are changed: random headers, the wrong charset, cut in half or empty", + "rule": "route=${route} corrupt mode=${mode} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "mode", + "type": "enum", + "description": "How the message is changed", + "default": "headers", + "example": "headers", + "pick": { + "from": "values", + "values": [ + "headers", + "charset", + "truncate", + "empty", + "remove-header" + ] + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "empty-body", + "group": "Messages", + "title": "Is sent with an empty body", + "description": "The message sent to the endpoint has an empty body", + "rule": "endpoint=${endpoint} corrupt mode=empty rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "missing-header", + "group": "Messages", + "title": "Is sent without a header", + "description": "A header is missing from the message sent to the endpoint", + "rule": "endpoint=${endpoint} corrupt mode=remove-header header=${header} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "header", + "type": "string", + "description": "The header, or a pattern with *", + "example": "X-Trace-Id", + "pick": { + "from": "headers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "poll-error", + "group": "Consumers and routes", + "title": "Polls fail", + "description": "The polls of a scheduled poll consumer fail, as when the source is not reachable", + "rule": "endpoint=${endpoint} error at=poll rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint a route consumes with a scheduled poll", + "example": "file:inbox", + "pick": { + "from": "pollingConsumers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "poll-skip", + "group": "Consumers and routes", + "title": "Polls are skipped", + "description": "The polls of a scheduled poll consumer are skipped, as a consumer that falls behind", + "rule": "endpoint=${endpoint} drop at=poll rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint a route consumes with a scheduled poll", + "example": "file:inbox", + "pick": { + "from": "pollingConsumers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "stall", + "group": "Consumers and routes", + "title": "The route stalls", + "description": "The route is suspended for a while, and its messages wait", + "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 it stalls", + "default": "20s", + "example": "20s" + } + ] + }, + { + "id": "flap", + "group": "Consumers and routes", + "title": "The route flaps", + "description": "The route stops and starts again several times", + "rule": "route=${route} lifecycle action=stop duration=${duration} repeat=${repeat} every=${every}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "duration", + "type": "duration", + "description": "How long it is down each time", + "default": "10s", + "example": "10s" + }, + { + "name": "repeat", + "type": "integer", + "description": "How many times", + "default": "3", + "example": "3" + }, + { + "name": "every", + "type": "duration", + "description": "The time from one time to the next", + "default": "1m", + "example": "1m" + } + ] + }, + { + "id": "removed", + "group": "Consumers and routes", + "title": "The route is removed", + "description": "The route is removed, and added back after a while", + "rule": "route=${route} lifecycle action=remove duration=${duration}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "duration", + "type": "duration", + "description": "How long it is gone", + "default": "30s", + "example": "30s" + } + ] + }, + { + "id": "dlq-unreachable", + "group": "Error handling", + "title": "The dead letter channel is unreachable", + "description": "The send of the dead letter channel fails, so the messages it handles are lost (add it before the rule that makes them fail)", + "rule": "endpoint=${endpoint} error at=dlq", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + } + ] + }, + { + "id": "step-error", + "group": "Error handling", + "title": "A step fails", + "description": "A step of a route fails, as when its processor throws", + "rule": "step=${step} error rate=${rate} until=${until}", + "placeholders": [ + { + "name": "step", + "type": "step", + "description": "The step", + "example": "enrich", + "pick": { + "from": "steps" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.1", + "example": "0.1" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "step-slow", + "group": "Error handling", + "title": "A step is slow", + "description": "A step of a route waits before its processor is called", + "rule": "step=${step} delay duration=${duration} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "step", + "type": "step", + "description": "The step", + "example": "enrich", + "pick": { + "from": "steps" + } + }, + { + "name": "duration", + "type": "duration", + "description": "The time, or a range of times", + "default": "1s", + "example": "1s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + } + ] + } +} + 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 8032ef961160..5edfa1a8982a 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 @@ -617,6 +617,29 @@ exchange gets which draw: with several threads (concurrent consumers, parallel p so two runs with the same seed can give different reports. With one thread and the same exchanges in the same order, the same seed gives the same faults. +== The model of the rules + +The rules are also described as data, for the tools that help to write them (a form in a terminal UI, an AI that turns +a request into rules, the docs): `META-INF/org/apache/camel/chaos/chaos-model.json` in the camel-chaos JAR, also in the +Camel catalog (`CamelCatalog.chaosModelJSon()`). It has the targets, the hooks of each target and the faults each one +offers (and whether it is reached only with `at=`), the parameters of each fault with their types and limits, the +options, the kinds and ops of an expectation, the limits, and recipes: known rules in plain words, grouped by what goes +wrong, each a rule with placeholders (`${endpoint}`, `${rate}`) and what to pick them from (the endpoints the routes +send to, the routes, the steps), such as: + +[source,json] +---- +{ + "id": "failed-after-success", + "group": "Downstream", + "title": "Did the work, then failed", + "rule": "endpoint=${endpoint} error at=reply until=${until}" +} +---- + +The model is made from the tables of the parser, so it says what the parser accepts; a tool still adds a rule through +`ChaosManager` or the CLI connector, which check it as for any other rule. + == Limits * A rule that ended stays in the rules (and the report) as `ended`. The hooks skip it at once, and the next change diff --git a/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/CamelCatalog.java b/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/CamelCatalog.java index a0bbd7f2e0e4..fd9e8d349236 100644 --- a/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/CamelCatalog.java +++ b/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/CamelCatalog.java @@ -679,6 +679,16 @@ public interface CamelCatalog { */ String devConsolesOpenApiSpec(); + /** + * The model of the chaos rules of camel-chaos, as JSON: the targets, the hooks of each target and the faults each + * offers, the parameters of each fault, the options, the expectations, the limits, and the recipes (known rules as + * templates), for the tools that help to write rules. + * + * @return the model, or null when the catalog has none + * @since 4.24 + */ + String chaosModelJSon(); + /** * Lists all the models (EIPs) summary details in JSon */ diff --git a/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/DefaultCamelCatalog.java b/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/DefaultCamelCatalog.java index 9dd4534fa102..71f78886ae58 100644 --- a/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/DefaultCamelCatalog.java +++ b/catalog/camel-catalog/src/main/java/org/apache/camel/catalog/DefaultCamelCatalog.java @@ -563,6 +563,11 @@ public class DefaultCamelCatalog extends AbstractCachingCamelCatalog implements return cache(BASE_RESOURCE_DIR + "/dev-consoles-openapi.json", this::loadResource); } + @Override + public String chaosModelJSon() { + return cache(BASE_RESOURCE_DIR + "/chaos-model.json", this::loadResource); + } + @Override public String listModelsAsJson() { return cache(LIST_MODELS_AS_JSON, () -> JsonMapper.serialize(findModelNames().stream() diff --git a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java index f7dfaf283c34..bef4e38d383f 100644 --- a/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java +++ b/catalog/camel-catalog/src/test/java/org/apache/camel/catalog/CamelCatalogTest.java @@ -2197,6 +2197,17 @@ public class CamelCatalogTest { Assertions.assertTrue(advisory.getComponents().contains("camel-bean")); } + @Test + public void chaosModel() { + String json = catalog.chaosModelJSon(); + Assertions.assertNotNull(json); + + JsonObject chaos = JsonMapper.deserialize(json).getJsonObject("chaos"); + Assertions.assertNotNull(chaos); + Assertions.assertFalse(chaos.getCollection("faults").isEmpty()); + Assertions.assertFalse(chaos.getCollection("recipes").isEmpty()); + } + @Test public void devConsolesOpenApiSpec() { String json = catalog.devConsolesOpenApiSpec(); diff --git a/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json new file mode 100644 index 000000000000..7505fb834508 --- /dev/null +++ b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json @@ -0,0 +1,1078 @@ +{ + "chaos": { + "description": "The chaos rules of camel-chaos: a rule is one line, the target, the fault and its parameters as name=value, then the options (such as at, rate, from, until) and optionally an expectation", + "syntax": "<target>=<pattern> <fault> [<parameter>=<value> ...] [<option>=<value> ...]", + "targets": [ + { + "name": "route", + "description": "A route id, with * for any characters; one route id (no pattern) for stall and lifecycle", + "pattern": true, + "pick": "routes" + }, + { + "name": "endpoint", + "description": "An endpoint URI pattern, with * for any characters; matched with or without the query parameters, and the \/\/ after the scheme does not count (kafka:orders* matches kafka:\/\/orders?brokers=b1)", + "pattern": true, + "pick": "endpoints" + }, + { + "name": "component", + "description": "The scheme of the endpoint URIs, with * for any characters (no ':')", + "pattern": true, + "pick": "components" + }, + { + "name": "step", + "description": "A step id (to1, or the id set with id(\"enrich\")), with * for any characters", + "pattern": true, + "pick": "steps" + } + ], + "hooks": [ + { + "name": "send", + "description": "The sends to a matching endpoint (to, toD, wireTap, enrich, recipientList, a ProducerTemplate...), before the producer is called, inside the error handler", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop", + "corrupt" + ], + "onlyWithAt": false + }, + { + "name": "reply", + "description": "The reply of such a send, after the producer succeeded: its side effect happened (only with at=reply)", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "corrupt" + ], + "onlyWithAt": true + }, + { + "name": "dlq", + "description": "The send of the dead letter channel to a matching endpoint (only with at=dlq)", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop", + "corrupt" + ], + "onlyWithAt": true + }, + { + "name": "poll", + "description": "The polls of the scheduled poll consumers of the routes", + "targets": [ + "endpoint", + "component" + ], + "faults": [ + "error", + "delay", + "drop" + ], + "onlyWithAt": false + }, + { + "name": "entry", + "description": "Each exchange that enters a matching route, before the first step", + "targets": [ + "route" + ], + "faults": [ + "corrupt" + ], + "onlyWithAt": false + }, + { + "name": "step", + "description": "Each matching step of a route, right before its processor is called", + "targets": [ + "step" + ], + "faults": [ + "error", + "delay" + ], + "onlyWithAt": false + } + ], + "faults": [ + { + "name": "error", + "description": "Fails the send, the poll or the step with an exception (a ChaosException without exception=), or the failure of an HTTP component for status=", + "targets": [ + "component", + "endpoint", + "step" + ], + "hooks": [ + "send", + "reply", + "dlq", + "poll", + "step" + ], + "parameters": [ + { + "name": "after", + "type": "duration", + "required": false, + "description": "Waits that long first, then fails, as a call that times out; the producer is not called", + "example": "5s", + "notAt": [ + "reply" + ] + }, + { + "name": "body", + "type": "string", + "required": false, + "description": "The body of the HTTP response (needs status=)", + "example": "no stock" + }, + { + "name": "exception", + "type": "class", + "required": false, + "description": "The class of the exception: a public Throwable with a public constructor taking a String or none (not an InterruptedException)", + "example": "java.net.ConnectException" + }, + { + "name": "header.<name>", + "type": "string", + "required": false, + "description": "A header of the HTTP response, such as header.Retry-After=5 (needs status=)", + "example": "5", + "prefix": "header." + }, + { + "name": "message", + "type": "string", + "required": false, + "description": "The message of the exception (the class needs a constructor taking a String)", + "example": "broker down" + }, + { + "name": "status", + "type": "integer", + "required": false, + "description": "The failure of an HTTP component for this status (not with exception= or message=)", + "example": "503", + "min": 300, + "max": 599, + "schemes": [ + "http", + "https", + "netty-http", + "undertow", + "vertx-http" + ] + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "delay", + "description": "Waits before the send, the poll or the step, as for a slow endpoint", + "targets": [ + "component", + "endpoint", + "step" + ], + "hooks": [ + "send", + "dlq", + "poll", + "step" + ], + "parameters": [ + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long to wait, or a range such as 100ms..2s for a draw between the two times (from the seeded sequence of the rule)", + "example": "500ms", + "range": true + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "drop", + "description": "A send that reports success without calling the producer, or a poll that is skipped", + "targets": [ + "component", + "endpoint" + ], + "hooks": [ + "send", + "dlq", + "poll" + ], + "parameters": [ + + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "corrupt", + "description": "Changes the message: its headers, the charset of its body, or its body", + "targets": [ + "component", + "endpoint", + "route" + ], + "hooks": [ + "send", + "reply", + "dlq", + "entry" + ], + "parameters": [ + { + "name": "header", + "type": "string", + "required": false, + "description": "The headers mode=remove-header removes: a name or a pattern with *, ignoring case (without it every header that is not Camel*)", + "example": "X-Trace-*" + }, + { + "name": "mode", + "type": "enum", + "required": true, + "description": "headers: the headers that are not Camel* get random values; charset: a text body becomes UTF-16 bytes; truncate: a String, byte[] or stream body ends after half of it; empty: the body becomes empty of its kind (or null); remove-header: removes headers", + "example": "headers", + "enum": [ + "headers", + "charset", + "truncate", + "empty", + "remove-header" + ] + } + ], + "options": [ + "at", + "expect", + "from", + "rate", + "until", + "within" + ] + }, + { + "name": "lifecycle", + "description": "Suspends, stops or removes the route of a route=<id> target, and brings it back after the duration; repeat= takes it down more than once", + "targets": [ + "route" + ], + "hooks": [ + + ], + "parameters": [ + { + "name": "action", + "type": "enum", + "required": true, + "description": "What to do to the route", + "example": "stop", + "enum": [ + "suspend", + "stop", + "remove" + ] + }, + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long the route stays down (at most camel.chaos.maxDuration)", + "example": "20s" + }, + { + "name": "every", + "type": "duration", + "required": false, + "description": "The time from one time the route goes down to the next, longer than duration=", + "example": "1m" + }, + { + "name": "repeat", + "type": "integer", + "required": false, + "description": "How many times to take the route down (needs every= when more than 1)", + "example": "3", + "min": 1, + "max": 100 + } + ], + "options": [ + "expect", + "from", + "within" + ] + }, + { + "name": "stall", + "description": "Short for lifecycle action=suspend: the route stalls for the duration", + "targets": [ + "route" + ], + "hooks": [ + + ], + "shortFor": "lifecycle action=suspend", + "parameters": [ + { + "name": "duration", + "type": "duration", + "required": true, + "description": "How long the route stays down (at most camel.chaos.maxDuration)", + "example": "20s" + }, + { + "name": "every", + "type": "duration", + "required": false, + "description": "The time from one time the route goes down to the next, longer than duration=", + "example": "1m" + }, + { + "name": "repeat", + "type": "integer", + "required": false, + "description": "How many times to take the route down (needs every= when more than 1)", + "example": "3", + "min": 1, + "max": 100 + } + ], + "options": [ + "expect", + "from", + "within" + ] + } + ], + "options": [ + { + "name": "at", + "type": "enum", + "required": false, + "description": "Narrows the rule to one hook of its target (reply and dlq are reached only with at=)", + "example": "send", + "enum": [ + "send", + "reply", + "dlq", + "poll", + "entry", + "step" + ] + }, + { + "name": "expect", + "type": "expectation", + "required": false, + "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed) or <route>:<kind><op><count>", + "example": "orders:handled<=0" + }, + { + "name": "from", + "type": "duration", + "required": false, + "description": "When the rule starts after it is added, such as after the warm-up (+30s or 30s)", + "example": "+30s" + }, + { + "name": "rate", + "type": "rate", + "required": false, + "description": "How often the rule fires: a probability above 0 and at most 1, every Nth match, or bursts (every match during the first part of each period); without it every match fires", + "example": "0.25", + "forms": [ + "0.25", + "every:4", + "burst:5s\/30s" + ] + }, + { + "name": "until", + "type": "countOrDuration", + "required": false, + "description": "When the rule ends: a number of matches or a time from its start (at most camel.chaos.maxDuration, its default)", + "example": "10m" + }, + { + "name": "within", + "type": "duration", + "required": false, + "description": "The time of the expectation (by default until the rule ends)", + "example": "2m" + } + ], + "expectation": { + "kinds": [ + { + "name": "completed", + "description": "The exchanges completed in the route" + }, + { + "name": "handled", + "description": "The exchanges handled by the error handler in the route" + }, + { + "name": "failed", + "description": "The exchanges failed in the route" + }, + { + "name": "redeliveries", + "description": "The redeliveries in the route" + } + ], + "ops": [ + { + "name": ">=", + "description": "at least" + }, + { + "name": "<=", + "description": "at most" + }, + { + "name": "=", + "description": "exactly" + } + ] + }, + "limits": { + "maxLength": 2000, + "maxRepeat": 100, + "maxDuration": "camel.chaos.maxDuration (default 10m): the longest until, delay, after, from, burst period and stall or lifecycle duration", + "time": "a number with a unit (ms, s, m, h, d), also combined as 1m30s; a number alone is milliseconds", + "quoting": "a value with spaces or ; is quoted with \" or ' (\\\", \\' and \\\\ inside quotes)" + }, + "recipes": [ + { + "id": "http-error", + "group": "Downstream", + "title": "Returns an error (HTTP status)", + "description": "An HTTP call fails with a status, as for a service that is down or overloaded: tests onException, redelivery and the dead letter channel", + "rule": "endpoint=${endpoint} error status=${status} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The HTTP endpoint the route calls", + "example": "https:\/\/inventory\/stock", + "pick": { + "from": "producers", + "schemes": [ + "http", + "https", + "netty-http", + "undertow", + "vertx-http" + ] + } + }, + { + "name": "status", + "type": "integer", + "description": "The HTTP status", + "default": "503", + "example": "503" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "connection-refused", + "group": "Downstream", + "title": "Refuses the connection", + "description": "The send fails as when the other side is not there", + "rule": "endpoint=${endpoint} error exception=java.net.ConnectException rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "slow", + "group": "Downstream", + "title": "Is slow", + "description": "Each send waits a different time within a range, as a slow service does", + "rule": "endpoint=${endpoint} delay duration=${duration} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "duration", + "type": "duration", + "description": "The time, or a range of times", + "default": "200ms..3s", + "example": "200ms..3s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "timeout", + "group": "Downstream", + "title": "Times out", + "description": "The send waits, then fails without calling the producer, as a call that times out", + "rule": "endpoint=${endpoint} error after=${after} exception=java.net.SocketTimeoutException rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "after", + "type": "duration", + "description": "How long before it times out", + "default": "5s", + "example": "5s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.1", + "example": "0.1" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "failed-after-success", + "group": "Downstream", + "title": "Did the work, then failed", + "description": "The send succeeds and then fails, as a reply that is lost: a redelivery sends it again, so this tests that a duplicate does no harm", + "rule": "endpoint=${endpoint} error at=reply until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "until", + "type": "countOrDuration", + "description": "How many sends fail", + "default": "1", + "example": "1" + } + ] + }, + { + "id": "outage", + "group": "Downstream", + "title": "Is down for a while", + "description": "Every send fails during the first part of each period, as outage windows", + "rule": "endpoint=${endpoint} error rate=burst:${outage}\/${period} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "outage", + "type": "duration", + "description": "How long each outage lasts", + "default": "10s", + "example": "10s" + }, + { + "name": "period", + "type": "duration", + "description": "The time from one outage to the next", + "default": "1m", + "example": "1m" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "corrupt-entry", + "group": "Messages", + "title": "Arrives corrupt", + "description": "The messages that enter the route are changed: random headers, the wrong charset, cut in half or empty", + "rule": "route=${route} corrupt mode=${mode} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "mode", + "type": "enum", + "description": "How the message is changed", + "default": "headers", + "example": "headers", + "pick": { + "from": "values", + "values": [ + "headers", + "charset", + "truncate", + "empty", + "remove-header" + ] + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "empty-body", + "group": "Messages", + "title": "Is sent with an empty body", + "description": "The message sent to the endpoint has an empty body", + "rule": "endpoint=${endpoint} corrupt mode=empty rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "missing-header", + "group": "Messages", + "title": "Is sent without a header", + "description": "A header is missing from the message sent to the endpoint", + "rule": "endpoint=${endpoint} corrupt mode=remove-header header=${header} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "header", + "type": "string", + "description": "The header, or a pattern with *", + "example": "X-Trace-Id", + "pick": { + "from": "headers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "poll-error", + "group": "Consumers and routes", + "title": "Polls fail", + "description": "The polls of a scheduled poll consumer fail, as when the source is not reachable", + "rule": "endpoint=${endpoint} error at=poll rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint a route consumes with a scheduled poll", + "example": "file:inbox", + "pick": { + "from": "pollingConsumers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "poll-skip", + "group": "Consumers and routes", + "title": "Polls are skipped", + "description": "The polls of a scheduled poll consumer are skipped, as a consumer that falls behind", + "rule": "endpoint=${endpoint} drop at=poll rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint a route consumes with a scheduled poll", + "example": "file:inbox", + "pick": { + "from": "pollingConsumers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.5", + "example": "0.5" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "stall", + "group": "Consumers and routes", + "title": "The route stalls", + "description": "The route is suspended for a while, and its messages wait", + "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 it stalls", + "default": "20s", + "example": "20s" + } + ] + }, + { + "id": "flap", + "group": "Consumers and routes", + "title": "The route flaps", + "description": "The route stops and starts again several times", + "rule": "route=${route} lifecycle action=stop duration=${duration} repeat=${repeat} every=${every}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "duration", + "type": "duration", + "description": "How long it is down each time", + "default": "10s", + "example": "10s" + }, + { + "name": "repeat", + "type": "integer", + "description": "How many times", + "default": "3", + "example": "3" + }, + { + "name": "every", + "type": "duration", + "description": "The time from one time to the next", + "default": "1m", + "example": "1m" + } + ] + }, + { + "id": "removed", + "group": "Consumers and routes", + "title": "The route is removed", + "description": "The route is removed, and added back after a while", + "rule": "route=${route} lifecycle action=remove duration=${duration}", + "placeholders": [ + { + "name": "route", + "type": "route", + "description": "The route", + "example": "orders", + "pick": { + "from": "routes" + } + }, + { + "name": "duration", + "type": "duration", + "description": "How long it is gone", + "default": "30s", + "example": "30s" + } + ] + }, + { + "id": "dlq-unreachable", + "group": "Error handling", + "title": "The dead letter channel is unreachable", + "description": "The send of the dead letter channel fails, so the messages it handles are lost (add it before the rule that makes them fail)", + "rule": "endpoint=${endpoint} error at=dlq", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + } + ] + }, + { + "id": "step-error", + "group": "Error handling", + "title": "A step fails", + "description": "A step of a route fails, as when its processor throws", + "rule": "step=${step} error rate=${rate} until=${until}", + "placeholders": [ + { + "name": "step", + "type": "step", + "description": "The step", + "example": "enrich", + "pick": { + "from": "steps" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.1", + "example": "0.1" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, + { + "id": "step-slow", + "group": "Error handling", + "title": "A step is slow", + "description": "A step of a route waits before its processor is called", + "rule": "step=${step} delay duration=${duration} rate=${rate} until=${until}", + "placeholders": [ + { + "name": "step", + "type": "step", + "description": "The step", + "example": "enrich", + "pick": { + "from": "steps" + } + }, + { + "name": "duration", + "type": "duration", + "description": "The time, or a range of times", + "default": "1s", + "example": "1s" + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + } + ] + } +} + diff --git a/components/camel-chaos/src/main/docs/chaos.adoc b/components/camel-chaos/src/main/docs/chaos.adoc index 8032ef961160..5edfa1a8982a 100644 --- a/components/camel-chaos/src/main/docs/chaos.adoc +++ b/components/camel-chaos/src/main/docs/chaos.adoc @@ -617,6 +617,29 @@ exchange gets which draw: with several threads (concurrent consumers, parallel p so two runs with the same seed can give different reports. With one thread and the same exchanges in the same order, the same seed gives the same faults. +== The model of the rules + +The rules are also described as data, for the tools that help to write them (a form in a terminal UI, an AI that turns +a request into rules, the docs): `META-INF/org/apache/camel/chaos/chaos-model.json` in the camel-chaos JAR, also in the +Camel catalog (`CamelCatalog.chaosModelJSon()`). It has the targets, the hooks of each target and the faults each one +offers (and whether it is reached only with `at=`), the parameters of each fault with their types and limits, the +options, the kinds and ops of an expectation, the limits, and recipes: known rules in plain words, grouped by what goes +wrong, each a rule with placeholders (`${endpoint}`, `${rate}`) and what to pick them from (the endpoints the routes +send to, the routes, the steps), such as: + +[source,json] +---- +{ + "id": "failed-after-success", + "group": "Downstream", + "title": "Did the work, then failed", + "rule": "endpoint=${endpoint} error at=reply until=${until}" +} +---- + +The model is made from the tables of the parser, so it says what the parser accepts; a tool still adds a rule through +`ChaosManager` or the CLI connector, which check it as for any other rule. + == Limits * A rule that ended stays in the rules (and the report) as `ended`. The hooks skip it at once, and the next change diff --git a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java new file mode 100644 index 000000000000..00ab7dc974e2 --- /dev/null +++ b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java @@ -0,0 +1,602 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.chaos; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.camel.component.chaos.ChaosRule.Action; +import org.apache.camel.component.chaos.ChaosRule.CorruptMode; +import org.apache.camel.component.chaos.ChaosRule.Expectation; +import org.apache.camel.component.chaos.ChaosRule.Fault; +import org.apache.camel.component.chaos.ChaosRule.Hook; +import org.apache.camel.component.chaos.ChaosRule.Target; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * The model of the chaos rules as data, for the tools that help to write them (the chaos screen of the terminal UI, an + * AI that turns a prompt into rules, the docs): the targets, the hooks of each target and the faults each hook offers, + * the parameters of each fault, the general options, the expectations, the limits, and the recipes (known rules in + * plain words, as templates). + * <p/> + * The structure comes from the tables of {@link ChaosRuleParser}, so it cannot drift from what the parser accepts; the + * descriptions and the recipes are written here. {@code ChaosModelTest} writes {@link #FILE} from it (and fails when + * the committed file differs), and checks that every recipe parses. The catalog copies the file + * ({@code CamelCatalog.chaosModelJSon()}). + */ +final class ChaosModel { + + /** Where the model is, in the jar and under {@code src/generated/resources}. */ + static final String FILE = "META-INF/org/apache/camel/chaos/chaos-model.json"; + + private ChaosModel() { + } + + /** The model as JSON, pretty printed (the content of {@link #FILE}). */ + static String toJson() { + return Jsoner.prettyPrint(model().toJson(), 2) + "\n"; + } + + static JsonObject model() { + JsonObject chaos = new JsonObject(); + chaos.put("description", "The chaos rules of camel-chaos: a rule is one line, the target, the fault and its" + + " parameters as name=value, then the options (such as at, rate, from, until) and" + + " optionally an expectation"); + chaos.put("syntax", "<target>=<pattern> <fault> [<parameter>=<value> ...] [<option>=<value> ...]"); + chaos.put("targets", targets()); + chaos.put("hooks", hooks()); + chaos.put("faults", faults()); + chaos.put("options", options()); + chaos.put("expectation", expectation()); + chaos.put("limits", limits()); + chaos.put("recipes", recipes()); + JsonObject root = new JsonObject(); + root.put("chaos", chaos); + return root; + } + + // ---- the targets and the hooks ---- + + private static JsonArray targets() { + JsonArray answer = new JsonArray(); + for (Target target : Target.values()) { + JsonObject jo = new JsonObject(); + jo.put("name", target.key); + switch (target) { + case ENDPOINT -> { + jo.put("description", "An endpoint URI pattern, with * for any characters; matched with or without" + + " the query parameters, and the // after the scheme does not count" + + " (kafka:orders* matches kafka://orders?brokers=b1)"); + jo.put("pattern", true); + jo.put("pick", "endpoints"); + } + case COMPONENT -> { + jo.put("description", "The scheme of the endpoint URIs, with * for any characters (no ':')"); + jo.put("pattern", true); + jo.put("pick", "components"); + } + case ROUTE -> { + jo.put("description", "A route id, with * for any characters; one route id (no pattern) for" + + " stall and lifecycle"); + jo.put("pattern", true); + jo.put("pick", "routes"); + } + case STEP -> { + jo.put("description", "A step id (to1, or the id set with id(\"enrich\")), with * for any" + + " characters"); + jo.put("pattern", true); + jo.put("pick", "steps"); + } + default -> throw new IllegalStateException("Unknown target " + target); + } + answer.add(jo); + } + return answer; + } + + private static JsonArray hooks() { + // the targets and the faults of each hook, from the hooks of each target the parser offers + Map<Hook, List<String>> targets = new LinkedHashMap<>(); + Map<Hook, Set<Fault>> faults = new LinkedHashMap<>(); + for (Target target : Target.values()) { + for (Map.Entry<Hook, Set<Fault>> entry : ChaosRuleParser.offered(target).entrySet()) { + targets.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(target.key); + faults.computeIfAbsent(entry.getKey(), k -> EnumSet.noneOf(Fault.class)).addAll(entry.getValue()); + } + } + JsonArray answer = new JsonArray(); + for (Hook hook : Hook.values()) { + JsonObject jo = new JsonObject(); + jo.put("name", hook.key); + jo.put("description", switch (hook) { + case SEND -> "The sends to a matching endpoint (to, toD, wireTap, enrich, recipientList, a" + + " ProducerTemplate...), before the producer is called, inside the error handler"; + case REPLY -> "The reply of such a send, after the producer succeeded: its side effect happened (only" + + " with at=reply)"; + case DLQ -> "The send of the dead letter channel to a matching endpoint (only with at=dlq)"; + case POLL -> "The polls of the scheduled poll consumers of the routes"; + case ENTRY -> "Each exchange that enters a matching route, before the first step"; + case STEP -> "Each matching step of a route, right before its processor is called"; + }); + jo.put("targets", new JsonArray(targets.getOrDefault(hook, List.of()))); + jo.put("faults", keys(faults.getOrDefault(hook, Set.of()))); + jo.put("onlyWithAt", ChaosRuleParser.onlyWithAt(hook)); + answer.add(jo); + } + return answer; + } + + // ---- the faults and their parameters ---- + + private static JsonArray faults() { + JsonArray answer = new JsonArray(); + for (Fault fault : Fault.values()) { + if (fault == Fault.LIFECYCLE) { + answer.add(fault("lifecycle", fault, false, + "Suspends, stops or removes the route of a route=<id> target, and brings it back after the" + + " duration; repeat= takes it down more than once")); + answer.add(fault("stall", fault, true, "Short for lifecycle action=suspend: the route stalls for the" + + " duration")); + } else { + answer.add(fault(fault.key, fault, false, switch (fault) { + case ERROR -> "Fails the send, the poll or the step with an exception (a ChaosException without" + + " exception=), or the failure of an HTTP component for status="; + case DELAY -> "Waits before the send, the poll or the step, as for a slow endpoint"; + case DROP -> "A send that reports success without calling the producer, or a poll that is skipped"; + case CORRUPT -> "Changes the message: its headers, the charset of its body, or its body"; + default -> throw new IllegalStateException("Unknown fault " + fault); + })); + } + } + return answer; + } + + private static JsonObject fault(String name, Fault fault, boolean stall, String description) { + JsonObject jo = new JsonObject(); + jo.put("name", name); + jo.put("description", description); + // the targets that have a hook offering the fault; stall and lifecycle act on a route at no hook + Set<String> targets = new TreeSet<>(); + Set<Hook> hooks = EnumSet.noneOf(Hook.class); + for (Target target : Target.values()) { + for (Map.Entry<Hook, Set<Fault>> entry : ChaosRuleParser.offered(target).entrySet()) { + if (entry.getValue().contains(fault)) { + targets.add(target.key); + hooks.add(entry.getKey()); + } + } + } + if (fault == Fault.LIFECYCLE) { + targets.add(Target.ROUTE.key); + } + jo.put("targets", new JsonArray(List.copyOf(targets))); + jo.put("hooks", keys(hooks)); + if (stall) { + jo.put("shortFor", "lifecycle action=suspend"); + } + JsonArray parameters = new JsonArray(); + for (String key : new TreeSet<>(ChaosRuleParser.faultKeys(fault, stall))) { + parameters.add(parameter(fault, key)); + } + jo.put("parameters", parameters); + jo.put("options", new JsonArray(List.copyOf(new TreeSet<>(ChaosRuleParser.optionKeys(fault))))); + return jo; + } + + private static JsonObject parameter(Fault fault, String key) { + String scope = fault.key + " " + key; + return switch (scope) { + case "error exception" -> param("exception", "class", false, + "The class of the exception: a public Throwable with a public constructor taking a String or none" + + " (not an InterruptedException)", + "java.net.ConnectException"); + case "error message" -> param("message", "string", false, + "The message of the exception (the class needs a constructor taking a String)", "broker down"); + case "error after" -> withNote(param("after", "duration", false, + "Waits that long first, then fails, as a call that times out; the producer is not called", "5s"), + "notAt", new JsonArray(List.of(Hook.REPLY.key))); + case "error status" -> { + JsonObject p = param("status", "integer", false, + "The failure of an HTTP component for this status (not with exception= or message=)", "503"); + p.put("min", 300); + p.put("max", 599); + p.put("schemes", new JsonArray(List.copyOf(new TreeSet<>(ChaosRuleParser.HTTP_SCHEMES)))); + yield p; + } + case "error body" -> param("body", "string", false, "The body of the HTTP response (needs status=)", + "no stock"); + case "error header." -> { + JsonObject p = param("header.<name>", "string", false, + "A header of the HTTP response, such as header.Retry-After=5 (needs status=)", "5"); + p.put("prefix", ChaosRuleParser.HEADER_PREFIX); + yield p; + } + case "delay duration" -> { + JsonObject p = param("duration", "duration", true, + "How long to wait, or a range such as 100ms..2s for a draw between the two times (from the" + + " seeded sequence of the rule)", + "500ms"); + p.put("range", true); + yield p; + } + case "corrupt mode" -> { + JsonObject p = param("mode", "enum", true, + "headers: the headers that are not Camel* get random values; charset: a text body becomes" + + " UTF-16 bytes; truncate: a String, byte[] or stream body ends" + + " after half of it; empty: the body becomes empty of its kind" + + " (or null); remove-header: removes headers", + CorruptMode.HEADERS.key); + p.put("enum", new JsonArray(corruptModes())); + yield p; + } + case "corrupt header" -> param("header", "string", false, + "The headers mode=remove-header removes: a name or a pattern with *, ignoring case (without it" + + " every header that is not Camel*)", + "X-Trace-*"); + case "lifecycle action" -> { + JsonObject p = param("action", "enum", true, "What to do to the route", Action.STOP.key); + JsonArray actions = new JsonArray(); + for (Action action : Action.values()) { + actions.add(action.key); + } + p.put("enum", actions); + yield p; + } + case "lifecycle duration" -> param("duration", "duration", true, + "How long the route stays down (at most camel.chaos.maxDuration)", "20s"); + case "lifecycle repeat" -> { + JsonObject p = param("repeat", "integer", false, + "How many times to take the route down (needs every= when more than 1)", "3"); + p.put("min", 1); + p.put("max", ChaosRuleParser.MAX_REPEAT); + yield p; + } + case "lifecycle every" -> param("every", "duration", false, + "The time from one time the route goes down to the next, longer than duration=", "1m"); + default -> throw new IllegalStateException("The chaos model has no description of the parameter " + scope); + }; + } + + // ---- the options, the expectation and the limits ---- + + private static JsonArray options() { + JsonArray answer = new JsonArray(); + for (String key : ChaosRuleParser.GENERAL_KEYS.stream().sorted().toList()) { + answer.add(switch (key) { + case "at" -> { + JsonObject p = param("at", "enum", false, + "Narrows the rule to one hook of its target (reply and dlq are reached only with at=)", + Hook.SEND.key); + JsonArray hooks = new JsonArray(); + for (Hook hook : Hook.values()) { + hooks.add(hook.key); + } + p.put("enum", hooks); + yield p; + } + case "rate" -> { + JsonObject p = param("rate", "rate", false, + "How often the rule fires: a probability above 0 and at most 1, every Nth match, or bursts" + + " (every match during the first part of each period);" + + " without it every match fires", + "0.25"); + p.put("forms", new JsonArray(List.of("0.25", "every:4", "burst:5s/30s"))); + yield p; + } + case "until" -> param("until", "countOrDuration", false, + "When the rule ends: a number of matches or a time from its start (at most" + + " camel.chaos.maxDuration, its default)", + "10m"); + case "from" -> param("from", "duration", false, + "When the rule starts after it is added, such as after the warm-up (+30s or 30s)", "+30s"); + case "expect" -> param("expect", "expectation", false, + "An expectation of a route after the rule is added: <route>:<count> (at least that many" + + " completed) or <route>:<kind><op><count>", + "orders:handled<=0"); + case "within" -> param("within", "duration", false, + "The time of the expectation (by default until the rule ends)", "2m"); + default -> throw new IllegalStateException("The chaos model has no description of the option " + key); + }); + } + return answer; + } + + private static JsonObject expectation() { + JsonObject jo = new JsonObject(); + JsonArray kinds = new JsonArray(); + for (Expectation.Kind kind : Expectation.Kind.values()) { + JsonObject k = new JsonObject(); + k.put("name", kind.key); + k.put("description", "The " + kind.words(2) + " in the route"); + kinds.add(k); + } + jo.put("kinds", kinds); + JsonArray ops = new JsonArray(); + for (Expectation.Op op : Expectation.Op.values()) { + JsonObject o = new JsonObject(); + o.put("name", op.key); + o.put("description", op.words); + ops.add(o); + } + jo.put("ops", ops); + return jo; + } + + private static JsonObject limits() { + JsonObject jo = new JsonObject(); + jo.put("maxLength", ChaosRuleParser.MAX_LENGTH); + jo.put("maxRepeat", ChaosRuleParser.MAX_REPEAT); + jo.put("maxDuration", "camel.chaos.maxDuration (default 10m): the longest until, delay, after, from, burst period" + + " and stall or lifecycle duration"); + jo.put("time", "a number with a unit (ms, s, m, h, d), also combined as 1m30s; a number alone is milliseconds"); + jo.put("quoting", "a value with spaces or ; is quoted with \" or ' (\\\", \\' and \\\\ inside quotes)"); + return jo; + } + + // ---- the recipes ---- + + /** + * A placeholder of a recipe: {@code ${name}} in its rule. The example is what the model test fills in to parse the + * recipe; the default is what a tool offers first. + */ + record Placeholder(String name, String type, String description, String example, String defaultValue, + JsonObject pick) { + } + + /** A known rule in plain words, as a template. */ + record Recipe(String id, String group, String title, String description, String rule, List<Placeholder> placeholders) { + } + + static List<Recipe> recipeList() { + List<Recipe> answer = new ArrayList<>(); + // downstream + answer.add(new Recipe( + "http-error", "Downstream", "Returns an error (HTTP status)", + "An HTTP call fails with a status, as for a service that is down or overloaded: tests onException," + + " redelivery and the dead letter channel", + "endpoint=${endpoint} error status=${status} rate=${rate} until=${until}", + List.of(httpEndpoint(), new Placeholder("status", "integer", "The HTTP status", "503", "503", null), + rate("0.25"), until("10m")))); + answer.add(new Recipe( + "connection-refused", "Downstream", "Refuses the connection", + "The send fails as when the other side is not there", + "endpoint=${endpoint} error exception=java.net.ConnectException rate=${rate} until=${until}", + List.of(producer(), rate("0.25"), until("10m")))); + answer.add(new Recipe( + "slow", "Downstream", "Is slow", + "Each send waits a different time within a range, as a slow service does", + "endpoint=${endpoint} delay duration=${duration} rate=${rate} until=${until}", + List.of(producer(), new Placeholder( + "duration", "duration", "The time, or a range of times", "200ms..3s", + "200ms..3s", null), rate("0.5"), until("10m")))); + answer.add(new Recipe( + "timeout", "Downstream", "Times out", + "The send waits, then fails without calling the producer, as a call that times out", + "endpoint=${endpoint} error after=${after} exception=java.net.SocketTimeoutException rate=${rate}" + + " until=${until}", + List.of(producer(), new Placeholder("after", "duration", "How long before it times out", "5s", "5s", null), + rate("0.1"), until("10m")))); + answer.add(new Recipe( + "failed-after-success", "Downstream", "Did the work, then failed", + "The send succeeds and then fails, as a reply that is lost: a redelivery sends it again, so this tests" + + " that a duplicate does no harm", + "endpoint=${endpoint} error at=reply until=${until}", + List.of(producer(), new Placeholder("until", "countOrDuration", "How many sends fail", "1", "1", null)))); + answer.add(new Recipe( + "outage", "Downstream", "Is down for a while", + "Every send fails during the first part of each period, as outage windows", + "endpoint=${endpoint} error rate=burst:${outage}/${period} until=${until}", + List.of(producer(), new Placeholder("outage", "duration", "How long each outage lasts", "10s", "10s", null), + new Placeholder("period", "duration", "The time from one outage to the next", "1m", "1m", null), + until("10m")))); + // messages + answer.add(new Recipe( + "corrupt-entry", "Messages", "Arrives corrupt", + "The messages that enter the route are changed: random headers, the wrong charset, cut in half or" + + " empty", + "route=${route} corrupt mode=${mode} rate=${rate} until=${until}", + List.of(route(), new Placeholder( + "mode", "enum", "How the message is changed", "headers", "headers", + pick("values", "values", corruptModes())), + rate("0.25"), until("10m")))); + answer.add(new Recipe( + "empty-body", "Messages", "Is sent with an empty body", + "The message sent to the endpoint has an empty body", + "endpoint=${endpoint} corrupt mode=empty rate=${rate} until=${until}", + List.of(producer(), rate("0.25"), until("10m")))); + answer.add(new Recipe( + "missing-header", "Messages", "Is sent without a header", + "A header is missing from the message sent to the endpoint", + "endpoint=${endpoint} corrupt mode=remove-header header=${header} rate=${rate} until=${until}", + List.of(producer(), new Placeholder( + "header", "string", "The header, or a pattern with *", "X-Trace-Id", + null, pick("headers", null, null)), rate("0.25"), until("10m")))); + // consumers and routes + answer.add(new Recipe( + "poll-error", "Consumers and routes", "Polls fail", + "The polls of a scheduled poll consumer fail, as when the source is not reachable", + "endpoint=${endpoint} error at=poll rate=${rate} until=${until}", + List.of(polled(), rate("0.5"), until("10m")))); + answer.add(new Recipe( + "poll-skip", "Consumers and routes", "Polls are skipped", + "The polls of a scheduled poll consumer are skipped, as a consumer that falls behind", + "endpoint=${endpoint} drop at=poll rate=${rate} until=${until}", + List.of(polled(), rate("0.5"), until("10m")))); + answer.add(new Recipe( + "stall", "Consumers and routes", "The route stalls", + "The route is suspended for a while, and its messages wait", + "route=${route} stall duration=${duration}", + List.of(route(), new Placeholder("duration", "duration", "How long it stalls", "20s", "20s", null)))); + answer.add(new Recipe( + "flap", "Consumers and routes", "The route flaps", + "The route stops and starts again several times", + "route=${route} lifecycle action=stop duration=${duration} repeat=${repeat} every=${every}", + List.of(route(), new Placeholder("duration", "duration", "How long it is down each time", "10s", "10s", null), + new Placeholder("repeat", "integer", "How many times", "3", "3", null), + new Placeholder("every", "duration", "The time from one time to the next", "1m", "1m", null)))); + answer.add(new Recipe( + "removed", "Consumers and routes", "The route is removed", + "The route is removed, and added back after a while", + "route=${route} lifecycle action=remove duration=${duration}", + List.of(route(), new Placeholder("duration", "duration", "How long it is gone", "30s", "30s", null)))); + // error handling + answer.add(new Recipe( + "dlq-unreachable", "Error handling", "The dead letter channel is unreachable", + "The send of the dead letter channel fails, so the messages it handles are lost (add it before the rule" + + " that makes them fail)", + "endpoint=${endpoint} error at=dlq", + List.of(producer()))); + answer.add(new Recipe( + "step-error", "Error handling", "A step fails", + "A step of a route fails, as when its processor throws", + "step=${step} error rate=${rate} until=${until}", + List.of(step(), rate("0.1"), until("10m")))); + answer.add(new Recipe( + "step-slow", "Error handling", "A step is slow", + "A step of a route waits before its processor is called", + "step=${step} delay duration=${duration} rate=${rate} until=${until}", + List.of(step(), new Placeholder("duration", "duration", "The time, or a range of times", "1s", "1s", null), + rate("0.25"), until("10m")))); + return answer; + } + + private static JsonArray recipes() { + JsonArray answer = new JsonArray(); + for (Recipe recipe : recipeList()) { + JsonObject jo = new JsonObject(); + jo.put("id", recipe.id()); + jo.put("group", recipe.group()); + jo.put("title", recipe.title()); + jo.put("description", recipe.description()); + jo.put("rule", recipe.rule()); + JsonArray placeholders = new JsonArray(); + for (Placeholder p : recipe.placeholders()) { + JsonObject po = new JsonObject(); + po.put("name", p.name()); + po.put("type", p.type()); + po.put("description", p.description()); + if (p.defaultValue() != null) { + po.put("default", p.defaultValue()); + } + po.put("example", p.example()); + if (p.pick() != null) { + po.put("pick", p.pick()); + } + placeholders.add(po); + } + jo.put("placeholders", placeholders); + answer.add(jo); + } + return answer; + } + + private static Placeholder producer() { + return new Placeholder( + "endpoint", "endpoint", "The endpoint the route sends to", "kafka:orders", null, + pick("producers", null, null)); + } + + private static Placeholder httpEndpoint() { + return new Placeholder( + "endpoint", "endpoint", "The HTTP endpoint the route calls", "https://inventory/stock", + null, pick("producers", "schemes", List.copyOf(new TreeSet<>(ChaosRuleParser.HTTP_SCHEMES)))); + } + + private static Placeholder polled() { + return new Placeholder( + "endpoint", "endpoint", "The endpoint a route consumes with a scheduled poll", + "file:inbox", null, pick("pollingConsumers", null, null)); + } + + private static Placeholder route() { + return new Placeholder("route", "route", "The route", "orders", null, pick("routes", null, null)); + } + + private static Placeholder step() { + return new Placeholder("step", "step", "The step", "enrich", null, pick("steps", null, null)); + } + + private static Placeholder rate(String value) { + return new Placeholder( + "rate", "rate", "How often: a probability, every:N or burst:<time>/<period>", value, value, + null); + } + + private static Placeholder until(String value) { + return new Placeholder( + "until", "countOrDuration", "When the rule ends: a number of matches or a time", value, + value, null); + } + + /** + * Where a tool picks the value of a placeholder from: the endpoints the routes send to (producers), the endpoints + * the routes consume with a scheduled poll (pollingConsumers), the routes, the steps, the headers, or the values + * listed. + */ + private static JsonObject pick(String from, String key, List<String> values) { + JsonObject jo = new JsonObject(); + jo.put("from", from); + if (values != null) { + jo.put(key, new JsonArray(values)); + } + return jo; + } + + private static List<String> corruptModes() { + List<String> answer = new ArrayList<>(); + for (CorruptMode mode : CorruptMode.values()) { + answer.add(mode.key); + } + return answer; + } + + // ---- helpers ---- + + private static JsonObject param(String name, String type, boolean required, String description, String example) { + JsonObject jo = new JsonObject(); + jo.put("name", name); + jo.put("type", type); + jo.put("required", required); + jo.put("description", description); + jo.put("example", example); + return jo; + } + + private static JsonObject withNote(JsonObject jo, String key, Object value) { + jo.put(key, value); + return jo; + } + + private static <E extends Enum<E>> JsonArray keys(Set<E> values) { + JsonArray answer = new JsonArray(); + for (E value : values) { + answer.add(switch (value) { + case Hook h -> h.key; + case Fault f -> f.key; + default -> value.name(); + }); + } + return answer; + } +} diff --git a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java index 879e027485f9..32165cb3a225 100644 --- a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java +++ b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java @@ -20,7 +20,6 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.EnumSet; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -56,11 +55,16 @@ final class ChaosRuleParser { */ static final Set<String> HTTP_SCHEMES = Set.of("http", "https", "netty-http", "undertow", "vertx-http"); - private static final Set<String> GENERAL_KEYS = Set.of("at", "rate", "until", "from", "expect", "within"); - private static final Set<String> ERROR_KEYS = Set.of("exception", "message", "status", "body", "after"); + // the options of a rule besides the parameters of its fault: all of them for the faults at a hook, and those of a + // stall or lifecycle rule, which acts on its route at no hook + static final Set<String> GENERAL_KEYS = Set.of("at", "rate", "until", "from", "expect", "within"); + static final Set<String> LIFECYCLE_KEYS = Set.of("from", "expect", "within"); + // the prefix of the response headers of error status=: header.<name>=<value> + static final String HEADER_PREFIX = "header."; + private static final Set<String> ERROR_KEYS + = Set.of("exception", "message", "status", "body", "after", HEADER_PREFIX); // the most times a lifecycle rule acts (repeat=) static final int MAX_REPEAT = 100; - private static final String HEADER_PREFIX = "header."; // the parameters of every fault, to tell a misspelt target from a parameter private static final Set<String> FAULT_KEYS = Set.of("exception", "message", "status", "body", "after", "duration", "mode", "header", "action", "repeat", @@ -72,6 +76,36 @@ final class ChaosRuleParser { private ChaosRuleParser() { } + /** + * The parameters of a fault, as the parser accepts them ({@code header.} stands for the {@code header.<name>} + * parameters of {@code error}). The chaos model ({@link ChaosModel}) is built from it. + * + * @param stall for {@code Fault.LIFECYCLE}: the rule says stall, which takes no action + */ + static Set<String> faultKeys(Fault fault, boolean stall) { + return switch (fault) { + case ERROR -> ERROR_KEYS; + case DELAY -> Set.of("duration"); + case DROP -> Set.of(); + case CORRUPT -> Set.of("mode", "header"); + case LIFECYCLE -> stall ? Set.of("duration", "repeat", "every") : Set.of("action", "duration", "repeat", "every"); + }; + } + + /** The options a rule with the fault takes besides the parameters of the fault. */ + static Set<String> optionKeys(Fault fault) { + return fault == Fault.LIFECYCLE ? LIFECYCLE_KEYS : GENERAL_KEYS; + } + + /** + * Whether a rule reaches the hook only with {@code at=} naming it: the send of the dead letter channel, so + * {@code endpoint=*} does not lose the messages that the dead letter channel should keep, and the reply, so a rule + * fails a send before the producer is called unless it says otherwise. + */ + static boolean onlyWithAt(Hook hook) { + return hook == Hook.DLQ || hook == Hook.REPLY; + } + /** * Splits the rules of {@code camel.chaos.rules}, which are separated by {@code ;}. A {@code ;} inside a quoted * value does not separate; empty parts are left out. @@ -215,7 +249,7 @@ final class ChaosRuleParser { } } case DELAY -> { - checkKeys(params, Set.of("duration"), b.fault); + checkKeys(params, faultKeys(Fault.DELAY, false), b.fault); String duration = params.get("duration"); if (duration == null) { throw new IllegalArgumentException( @@ -243,9 +277,9 @@ final class ChaosRuleParser { checkMax("duration", duration, b.duration, maxDuration); } } - case DROP -> checkKeys(params, Set.of(), b.fault); + case DROP -> checkKeys(params, faultKeys(Fault.DROP, false), b.fault); case CORRUPT -> { - checkKeys(params, Set.of("mode", "header"), b.fault); + checkKeys(params, faultKeys(Fault.CORRUPT, false), b.fault); String mode = params.get("mode"); if (mode == null) { throw new IllegalArgumentException("the fault corrupt needs mode=<mode> (" + MODES + ")"); @@ -452,9 +486,7 @@ final class ChaosRuleParser { * The parameters of {@code error}. Returns whether the error is the failure of an HTTP component. */ private static boolean errorParameters(ChaosRule.Builder b, Map<String, String> params, ClassResolver classResolver) { - Set<String> keys = new HashSet<>(ERROR_KEYS); - keys.add(HEADER_PREFIX); - checkKeys(params, keys, Fault.ERROR); + checkKeys(params, faultKeys(Fault.ERROR, false), Fault.ERROR); Map<String, String> headers = new LinkedHashMap<>(); params.forEach((k, v) -> { @@ -561,8 +593,7 @@ final class ChaosRuleParser { "stall is short for lifecycle action=suspend, so it takes no action=; use lifecycle action=..."); } // at, rate and until are rejected above - checkKeys(params, b.stall ? Set.of("duration", "repeat", "every") : Set.of("action", "duration", "repeat", "every"), - word, Set.of("from", "expect", "within")); + checkKeys(params, faultKeys(Fault.LIFECYCLE, b.stall), word, optionKeys(Fault.LIFECYCLE)); if (b.target != Target.ROUTE) { throw new IllegalArgumentException( "the fault " + word + " acts on a route, so it takes only a route=<id> target, not " + b.target.key @@ -706,11 +737,7 @@ final class ChaosRuleParser { } Set<Hook> hooks = EnumSet.noneOf(Hook.class); for (Map.Entry<Hook, Set<Fault>> entry : offered.entrySet()) { - // the send of the dead letter channel is reached only with at=dlq, so endpoint=* does not lose the messages - // that the dead letter channel should keep; the reply only with at=reply, so a rule fails a send before the - // producer is called unless it says otherwise - boolean asked = b.at == null - ? entry.getKey() != Hook.DLQ && entry.getKey() != Hook.REPLY : b.at == entry.getKey(); + boolean asked = b.at == null ? !onlyWithAt(entry.getKey()) : b.at == entry.getKey(); if (asked && entry.getValue().contains(b.fault)) { hooks.add(entry.getKey()); } diff --git a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java new file mode 100644 index 000000000000..924b3a036795 --- /dev/null +++ b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.chaos; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.component.chaos.ChaosModel.Placeholder; +import org.apache.camel.component.chaos.ChaosModel.Recipe; +import org.apache.camel.component.chaos.ChaosRule.Hook; +import org.apache.camel.component.chaos.ChaosRule.Target; +import org.apache.camel.impl.engine.DefaultClassResolver; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The model of the chaos rules ({@link ChaosModel}): the committed file is what the model gives (write it again with + * {@code -Dcamel.chaos.updateModel=true}), it agrees with the parser, and every recipe parses. + */ +class ChaosModelTest { + + private static final Path GENERATED = Path.of("src/generated/resources").resolve(ChaosModel.FILE); + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([a-zA-Z]+)}"); + private static final long MAX = TimeUnit.MINUTES.toMillis(10); + + @Test + void theCommittedFileIsTheModel() throws Exception { + String expected = ChaosModel.toJson(); + if (Boolean.getBoolean("camel.chaos.updateModel")) { + Files.createDirectories(GENERATED.getParent()); + Files.writeString(GENERATED, expected, StandardCharsets.UTF_8); + } + assertThat(Files.readString(GENERATED, StandardCharsets.UTF_8)) + .as("%s is out of date: run the test with -Dcamel.chaos.updateModel=true and commit the file", GENERATED) + .isEqualTo(expected); + // and it is in the jar, where the catalog finds it + try (InputStream in = getClass().getClassLoader().getResourceAsStream(ChaosModel.FILE)) { + assertThat(in).isNotNull(); + } + } + + @Test + void theModelIsValidJsonWithTheChaosKey() throws Exception { + JsonObject root = (JsonObject) Jsoner.deserialize(ChaosModel.toJson()); + assertThat(root).containsOnlyKeys("chaos"); + assertThat(chaos()).containsKeys("targets", "hooks", "faults", "options", "expectation", "limits", "recipes"); + } + + @Test + void theHooksAndFaultsAreThoseOfTheParser() { + JsonArray hooks = (JsonArray) chaos().get("hooks"); + assertThat(hooks).hasSize(Hook.values().length); + for (Object o : hooks) { + JsonObject hook = (JsonObject) o; + Hook h = hook(hook.getString("name")); + assertThat(hook.getBoolean("onlyWithAt")).isEqualTo(h == Hook.DLQ || h == Hook.REPLY); + for (Object t : (JsonArray) hook.get("targets")) { + Target target = target((String) t); + Set<String> faults = new HashSet<>(); + ChaosRuleParser.offered(target).get(h).forEach(f -> faults.add(f.key)); + assertThat(new HashSet<>((JsonArray) hook.get("faults"))).containsAll(faults); + } + } + assertThat(names("faults")).containsExactly("error", "delay", "drop", "corrupt", "lifecycle", "stall"); + JsonObject stall = byName("faults", "stall"); + assertThat(stall.getString("shortFor")).isEqualTo("lifecycle action=suspend"); + assertThat(paramNames(stall)).containsExactly("duration", "every", "repeat"); + assertThat(paramNames(byName("faults", "lifecycle"))).containsExactly("action", "duration", "every", "repeat"); + assertThat(paramNames(byName("faults", "error"))) + .containsExactly("after", "body", "exception", "header.<name>", "message", "status"); + assertThat(strings(byName("faults", "lifecycle").get("options"))).containsExactly("expect", "from", "within"); + assertThat(strings(byName("faults", "error").get("hooks"))).containsExactly("send", "reply", "dlq", "poll", "step"); + assertThat(names("options")).containsExactly("at", "expect", "from", "rate", "until", "within"); + } + + @Test + void theRecipesHaveUniqueIdsAndTheirPlaceholders() { + Set<String> ids = new HashSet<>(); + for (Recipe recipe : ChaosModel.recipeList()) { + assertThat(ids.add(recipe.id())).as("recipe id %s", recipe.id()).isTrue(); + Set<String> used = new LinkedHashSet<>(); + Matcher m = PLACEHOLDER.matcher(recipe.rule()); + while (m.find()) { + used.add(m.group(1)); + } + Set<String> declared = new LinkedHashSet<>(); + recipe.placeholders().forEach(p -> declared.add(p.name())); + assertThat(declared).as("the placeholders of recipe %s", recipe.id()).isEqualTo(used); + } + } + + static List<Recipe> recipes() { + return ChaosModel.recipeList(); + } + + @ParameterizedTest + @MethodSource("recipes") + void everyRecipeParsesWithItsExamplesAndDefaults(Recipe recipe) { + // the examples + ChaosRuleParser.parse(fill(recipe, false), new DefaultClassResolver(), MAX); + // the defaults, with the examples where there is no default + ChaosRuleParser.parse(fill(recipe, true), new DefaultClassResolver(), MAX); + } + + @Test + void theModeOfACorruptRecipeOffersEveryMode() { + Recipe recipe = ChaosModel.recipeList().stream().filter(r -> r.id().equals("corrupt-entry")).findFirst() + .orElseThrow(); + Placeholder mode = recipe.placeholders().stream().filter(p -> p.name().equals("mode")).findFirst().orElseThrow(); + for (Object value : (JsonArray) mode.pick().get("values")) { + String rule = fill(recipe, true).replace("mode=headers", "mode=" + value); + ChaosRuleParser.parse(rule, new DefaultClassResolver(), MAX); + } + } + + private static String fill(Recipe recipe, boolean defaults) { + String rule = recipe.rule(); + for (Placeholder p : recipe.placeholders()) { + String value = defaults && p.defaultValue() != null ? p.defaultValue() : p.example(); + rule = rule.replace("${" + p.name() + "}", value); + } + assertThat(rule).doesNotContain("${"); + return rule; + } + + private static JsonObject chaos() { + return (JsonObject) ChaosModel.model().get("chaos"); + } + + private static List<String> names(String key) { + return ((JsonArray) chaos().get(key)).stream().map(o -> ((JsonObject) o).getString("name")).toList(); + } + + private static JsonObject byName(String key, String name) { + return (JsonObject) ((JsonArray) chaos().get(key)).stream() + .filter(o -> name.equals(((JsonObject) o).getString("name"))).findFirst().orElseThrow(); + } + + private static List<String> paramNames(JsonObject fault) { + return ((JsonArray) fault.get("parameters")).stream().map(o -> ((Map<?, ?>) o).get("name").toString()).toList(); + } + + private static List<String> strings(Object array) { + return ((List<?>) array).stream().map(Object::toString).toList(); + } + + private static Hook hook(String key) { + for (Hook h : Hook.values()) { + if (h.key.equals(key)) { + return h; + } + } + throw new AssertionError("No hook " + key); + } + + private static Target target(String key) { + for (Target t : Target.values()) { + if (t.key.equals(key)) { + return t; + } + } + throw new AssertionError("No target " + key); + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc b/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc index 5b231a985486..7dc5f8090f11 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-catalog.adoc @@ -48,6 +48,7 @@ org └── camel └── catalog ├── beans (JSON schema) + ├── chaos-model.json (the model of the chaos rules) ├── components (JSON schema) ├── dataformats (JSON schema) ├── dev-consoles (JSON schema) @@ -77,6 +78,11 @@ covering every dev console endpoint, so the same contract used internally to ser dev console is also available standalone for tooling. A `dev-consoles.properties` index file lists all available dev console names. +The `chaos-model.json` file, since Camel 4.24, is the model of the rules of +xref:next@components:others:chaos.adoc[camel-chaos]: the targets, the hooks of each target and the faults each one +offers, the parameters of each fault, the options, the expectations, the limits, and recipes (known rules in plain +words, as templates). It is for the tools that help to write rules, such as a form or an AI. + The `docs` directory contains the AsciiDoc documentation pages for all components, EIPs, data formats, languages, and other artifacts. A `docs.properties` index file lists all available documentation names. @@ -244,4 +250,7 @@ String summary = catalog.listDevConsolesAsJson(); // the OpenAPI 3.0 specification aggregated across every dev console endpoint String openApiSpec = catalog.devConsolesOpenApiSpec(); + +// the model of the chaos rules of camel-chaos +String chaosModel = catalog.chaosModelJSon(); ---- diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCatalogMojo.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCatalogMojo.java index 2fcac3ef4933..d286557b733d 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCatalogMojo.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/PrepareCatalogMojo.java @@ -437,6 +437,7 @@ public class PrepareCatalogMojo extends AbstractMojo { Set<String> beans = executeBeans(); executeApis(); Set<String> consoles = executeDevConsoles(); + executeChaosModel(); Set<String> others = executeOthers(); executeDocuments(components, dataformats, languages, others); executeDocs(); @@ -917,6 +918,22 @@ public class PrepareCatalogMojo extends AbstractMojo { return apiNames; } + /** + * Copies the model of the chaos rules of camel-chaos into the catalog (CamelCatalog.chaosModelJSon()). + */ + protected void executeChaosModel() throws Exception { + List<Path> models = allJsonFiles.stream() + .filter(p -> p.endsWith("META-INF/org/apache/camel/chaos/chaos-model.json")) + .toList(); + if (models.size() != 1) { + getLog().warn("Expected one chaos-model.json (camel-chaos), found " + models.size()); + return; + } + Path target = consolesOutDir.toPath().resolve("../chaos-model.json"); + FileUtil.updateFile(target, Files.readString(models.get(0))); + getLog().info("Copied the chaos model of camel-chaos"); + } + protected Set<String> executeDevConsoles() throws Exception { Path consolesOutDir = this.consolesOutDir.toPath();
