Repository navigation
fix(chaos): compound runs and resilience faults go through the safety guard and the disruption lease - #375
Merged
Conversation
… guard and the disruption lease
A compound run and a resilience run called the chaos providers past the
safety guard and past the lease that keeps plans one at a time. A
chaos:run caller could post a compound POD_KILL with targetAll, which on
the kubernetes provider force-deletes every broker and KRaft controller
at once, while the same fault as a plan step is refused with "Plan would
affect ALL 3 brokers". A CI resilience run could kill broker 1 while a
leader-cascade plan killed broker 0, leaving two of three brokers down
under min.insync.replicas=2, and either one's cleanup or rollback undid
the other's fault.
POST /api/disruptions/compound now checks the chaos limits first, so a
fault with no spec is still refused by its place, then counts its faults
as the steps of a plan, each without its targetTopic: a compound fault
is never aimed at a topic's leader, and the guard would count the leader
instead. A plan the guard refuses is a 400, "No fault was triggered:"
and its reasons. The run then takes the lease, or answers 409 while
another disruption holds it, and gives it back when its faults end. A
concurrent run waits for each fault at least delayBeforeSec +
chaosDurationSec + 120 s, as a sequential run does: with only
timeoutSec, 120 s by default, a longer fault was reported timed out and
the lease went back while the fault still ran.
POST /api/resilience and /api/resilience/scenarios/{id} ask the same
guard of the fault, as a one-step plan, after their other checks and
before the stream starts: a 409 while the cluster is taken, a 400 when
the guard refuses. The run itself takes the lease just before its fault
goes in and holds it through the recovery wait and the report; one that
finds the cluster taken then ends ERROR with no fault injected. A
resilience run still gets no rollback and no check of the Kafka pods
before its fault.
The lease's 409 and REJECTED messages no longer say the holder is a
plan, and the DisruptionOrchestrator javadoc no longer says the lease
cannot be bypassed. Book chapters 01, 02, 05, 07, 10, 11, the
glossary, Part III's intro and the stretched-cluster guide say what a
resilience run now gets. The guide's A6 site-B transient hits every
broker, so the guard refuses it as a resilience run too, and the guide
now has it run outside Kates with kubectl. Four anchored caveat refs
are re-pointed; no caveat text changes.
This was referenced Oct 17, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A compound run and a resilience run reach the chaos providers past the two checks a disruption plan gets: the safety guard, which counts the brokers the faults hit, and the lease that keeps disruptions one at a time per cluster. Checked in the code:
POST /api/disruptions/compound(DisruptionAnalysisResource.executeCompound) ran onlyCompoundChaosOrchestrator.checkLimits, whose javadoc called it "the only check its faults get". Achaos:runcaller could post{"faults":[{"faultSpec":{"experimentName":"x","disruptionType":"POD_KILL","targetAll":true}}]}.providerNamedefaults tokubernetes, whose POD_KILL force-deletes every pod withstrimzi.io/component-type=kafkaat once, every broker and every KRaft controller. The same fault as a plan step is REJECTED with "Plan would affect ALL 3 brokers".ResilienceOrchestrator.executecallschaosCoordinator.triggerFaultwith neither the guard nor the lease. A CI resilience run could kill broker 1 while an engineer's leader-cascade plan killed broker 0. Each passes alone; together two of three brokers are down undermin.insync.replicas=2, and everyacks=allproducer on the cluster fails.cleanup(), which deletes everymanaged-by=katesNetworkPolicy, so it cut short a running plan's partition. A plan's SCALE_DOWN rollback restores every snapshot in the namespace, a resilience run's scale-down included.executeConcurrentwaited for each fault onlytimeoutSec, 120 s by default (DisruptionDtos.java:15). A longer fault was reported "Timed out after 120s" while it still ran, so a lease released in afinallywould go back with the fault still in.DisruptionOrchestrator.executesaid the lease "cannot be bypassed by adding a caller". TheDisruptionLauncher409 and the REJECTED warning say the holder is a disruption plan, which after this PR it need not be.Changes
DisruptionAnalysisResource.executeCompound(POST /api/disruptions/compound), in this order:compoundOrchestrator.checkLimits(faults), now public, runs first. A fault with nofaultSpecis still refused by its place (faults[i].faultSpec); the guard reads every spec and would throw on a null one.compound, one stepfaults[i]per fault, each withtargetTopicblanked. A compound fault is never aimed at a topic's leader (PodTargetsignores it, and the Litmus provider uses it as the DNSTARGET_HOSTNAMES), and the guard'stargeted()would count the leader instead. A planvalidatePlanrefuses is a 400:No fault was triggered:and its errors, joined by;.tryAcquire(currentTarget(), "compound#<uuid>"). While another disruption holds the lease: 409Conflict, "Another disruption is running against , so no fault was triggered".try { … } finally { release }.The OpenAPI 400 text names the guard's refusal, and a 409 response is documented.
CompoundChaosOrchestratorexecuteConcurrentwaits for each faultmax(timeoutSec, delayBeforeSec + chaosDurationSec + 120)seconds, the waitexecuteSequentialalready gives it. A timeout names that wait.checkLimitsis public. Its javadoc, in the same number of lines, says the resource asks it first, then the guard, and holds the lease.ResilienceResource(POST /api/resilienceandPOST /api/resilience/scenarios/{id})unsafeOrBusy(FaultSpec). It answers 409 whileconcurrencyGuard.isBusy(currentTarget()), asDisruptionLauncherdoes for a plan. Otherwise it validates the fault as a one-step plan (resilience, stepchaosSpec,targetTopicblanked) and answers 400,No fault was injected:and its errors. Both useApiError.of(...)withBad Request/Conflictand nofieldErrors.ResilienceOrchestrator.executeresilience:<runId>#<uuid>) just before step 4 and releases it in afinallyon the outer try, so it is held through the fault, the recovery wait and the report. A run that finds the lease taken endsERROR, "Another disruption is running against , so no fault was injected", and no fault goes in.triggerFault: refused after the probes had started, their thread would never stop.Messages and javadoc, each edited in place in the same number of lines, so no CLI ref below them moves:
DisruptionLauncher409: "Another disruption (a plan, a compound run or a resilience run's fault) is running against . Wait for it to finish before starting another."DisruptionOrchestrator's REJECTED warning: "another disruption is already running against …". Itsexecutejavadoc drops "cannot be bypassed by adding a caller": plans take the lease there, and compound runs and resilience faults take the same lease themselves.DisruptionConcurrencyGuard's class javadoc: the lease serialises plans, compound runs and resilience faults per target.kates mcp: four anchored caveat refs re-pointed, anchors unchanged, no caveat text changed.mcp_tools_runs.go:DisruptionAnalysisResource.java:110-141→118-192,ResilienceResource.java:97-127→107-137.mcp_tools_scenario.go:ResilienceOrchestrator.java:144-145→158-159.mcp_tools_chaos.go:CompoundChaosOrchestrator.java:150-169→155-174.mcpCaveatActivityDisruptionRows("resilience runs and compound chaos write no row") stays true.Docs
ERRORwhen the lease is taken at injection.kubectl delete pod -lasmake gamedaydoes, while a REST INTEGRITY run writes (so norpoMs).What callers see. Runs that used to start are now refused: on a 1-broker cluster ("Plan would affect ALL 1 brokers"), with a
kates.chaos.kafka.namespaceor label that matches no broker ("No broker pods found"), withtargetAllon the brokers, or with a drain of the node holding every broker. Parallel resilience runs now take turns: the second gets a 409 before it starts, or endsERRORwith no fault if it finds the lease taken when itssteadyStateSecis up.Tests
New:
DisruptionAnalysisResourceCompoundTest(plain JUnit, the guard and orchestrator mocked, a realDisruptionConcurrencyGuard):anUnsafeCompoundRunIsRefusedBeforeAnyFault: 400 naming the guard's error, neitherexecuteConcurrentnorexecuteSequentialcalled;aCompoundRunWhileTheLeaseIsHeldIsAConflict: 409, no fault;theLeaseIsHeldWhileTheFaultsRunAndReleasedAfter: busy inside the orchestrator call, free after a 200 and after the orchestrator throws;eachFaultIsAStepCountedWithoutItsTargetTopic: the planvalidatePlangets.DisruptionResourceTest.aCompoundRunWithNoBrokerToCountIsRefused: the guard wired end to end, with atargetAllPOD_KILL. It accepts either "No fault was triggered: No broker pods found" or "No fault was triggered: Plan would affect ALL ".@QuarkusTest's Kubernetes client reads the local kubeconfig, which on the machine this was built on reached a kind cluster with 3 brokers. WithtargetAllthe run is refused either way and nothing is killed.aCompoundRunWithAFaultOutsideTheChaosLimitsIsRefusedis unchanged and still passes, so the limits are still asked first.ResilienceOrchestratorLeaseTest:aFaultWhileAnotherDisruptionHoldsTheLeaseIsNotInjected:ERROR,triggerFaultnever called;theLeaseIsHeldFromTheFaultThroughRecovery: busy insidetriggerFaultand inside the probe evaluation;theLeaseIsReleasedWhenTheRunEndsOrFails: afterCOMPLETED, and aftertriggerFaultthrows.ResilienceResourceTest:anUnsafeFaultIsRefusedBeforeTheStream(400) andaRunWhileADisruptionHoldsTheLeaseIsAConflict(409), each onPOST /api/resilienceand/api/resilience/scenarios/broker-crash, with the orchestrator never called.CompoundChaosOrchestratorTest.aConcurrentFaultLongerThanTimeoutSecIsWaitedFor:timeoutSec0 and a fault that ends after about 200 ms. Before the fix it was "Timed out after 0s".Changed:
ResilienceResourceTestandResilienceResourceStreamTestmockDisruptionSafetyGuardwith a safe answer in@BeforeEach. A bare mock returns null, and the real guard finds no broker in CI.ResilienceOrchestratorBenchmarkStatusTest,ResilienceOrchestratorChaosStartTest,ResilienceOrchestratorRecoveryTestandResilienceOrchestratorTestRunIdTestset the newconcurrencyGuardfield.Run locally, all passing:
cd kates && ./mvnw -o test -Dquarkus.grpc.codegen.skip=true -Dquarkus.http.test-port=0: 1,477 tests, 0 failures, 0 errors, 0 skipped. The "Cleanup failed" WARN lines come from mock-server 500s the tests inject on purpose.spotless:checkon JDK 25: clean.cd cli && go test ./..., including the caveat-ref anchor tests against thekates/edits;go vet ./...;golangci-lint run --new-from-rev=origin/main ./...: 0 issues;govulncheck ./...: no vulnerabilities.scripts/check-cli-style.sh,scripts/check-cli-compat.sh.scripts/check-book-style.sh: the structure ratchet holds for all 38 pages.scripts/gen-version-matrix.sh --check,scripts/gen-chart-table.sh --check, which also coverdocs/stretched-kafka.scripts/check-book-contrast.py: all 170 colour pairs meet WCAG AA.scripts/check-mermaid.sh: all 156 diagrams parse.check-book-fragments.pyfinds all 7,460 fragment links landing on an id.check-versions.sh, yamllint),make check-help, actionlint and shellcheck. No chart, Makefile,.githubor shell file changed.Left to CI:
book.yml, TeX Live with ebgaramond);native.yml, triggered bykates/**);*ITintegration tests (Testcontainers, Docker);github-advanced-securityAI scan, which is red on every PR (a GitHub-side error).Decisions
delayBeforeSec + chaosDurationSec + 120s.triggerFaultthrough the recovery wait and the report, with onefinally.kates.chaos.kafka.namespace/cluster, as for plans, not on the spec'stargetNamespace. Resilience runs aimed at other namespaces now take turns too.Deliberately not done
maxAffectedBrokers. The docs say so.chaosFuture.gettimes out (delayBeforeSec + chaosDurationSec + 120s) keeps the lease until the run'sfinally, but litmus-crd may still be running the fault after that. Plans have the same window.steadyStateSecendsERRORafter its benchmark has run.12-deployment.md:648is left alone: it is out of scope, and fix(chaos): orphan recovery undoes every fault left from before the Kates API started, and NetworkPolicies in any namespace #374 edits next to it.Landing
Lands after #373 (plan PR 4) and #374 (plan PR 5), which edit files this PR also edits. No Java file is shared.
docs/book/10-cli-reference.md.cli/cmd/mcp_tools_runs.go(fix(chaos): orphan recovery undoes every fault left from before the Kates API started, and NetworkPolicies in any namespace #374 near 1931 and 2391, this PR at 2395 and 2397),cli/cmd/mcp_tools_chaos.go,docs/book/07-chaos-practice.md(fix(chaos): orphan recovery undoes every fault left from before the Kates API started, and NetworkPolicies in any namespace #374 near 631 and 662, this PR at 659) anddocs/book/11-api-reference.md(fix(chaos): orphan recovery undoes every fault left from before the Kates API started, and NetworkPolicies in any namespace #374 near 713, this PR at 711).Once each has merged, rebase this branch on main. Where a landed change moved lines in a Java file a caveat ref cites, re-point the ref from a diff line map of that file, then rerun
cd cli && go test ./cmd -run TestMCPCaveat.