Repository navigation
A prototype harness for fault-injection tests in OpenShell #5070
mpereira
started this conversation in
Design Discussion
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Over the last few days I've been playing around with ideas for running OpenShell fault-injection tests. I came up with a prototype harness that runs declarative experiments. In an experiment you lay out a timeline. Some workloads run, and operations hit the system while they're running, like killing a process or pausing a container. Then you write down how you expect it to end. Which workloads finish? What did they write? What state is the system left in? The harness runs the whole thing and tells you whether you were right.
Every run also leaves the same kind of output behind: logs, metrics, a timeline of events and charts of all of it. A flow where three sandboxes, a gateway and an outage overlap is hard to follow in logs alone. On a chart you can see what happened when, and that makes it a lot easier to understand the problem.
It lives in
e2e/chaoson my fork.This post walks through it: we'll run the smallest experiment and look at what it left behind, and then run a more complex one and pick up the concepts as we go.
The smallest experiment
An experiment is a YAML file. Here's
hello-world. One sandbox writes a file, and a check makes sure it did:There are already a few concepts in there.
A workload is something to run, and how many copies of it you want. Each copy is an instance, named
<workload>-N. Sohelloruns ashello-0. The only kind of workload so far isshell. Itsrunscript goes into each instance and runs there, not on the machine where the harness is. Every instance has an output directory the harness collects when it's done.The harness doesn't care what an instance actually is. It just needs to be able to create one, ask how it's doing, collect its output and delete it. That could be:
The adapter decides which, and I'll get to adapters later. Every run in this post uses OpenShell sandboxes.
Checks are what you believe should be true afterwards. A workload check runs once per instance.
exit-codelooks at how the script ended.shellruns a script of yours, and exit 0 means the check passed.Running it
You run an experiment with
chaos run. It needs a gateway to talk to, and a mise task starts one for you:It builds the gateway, CLI and images, and starts a fresh Docker-backed gateway. Then it runs
chaos runagainst it and tears the gateway down. When the run is done, it prints the check results and the verdict:chaos shareturns a run into a Markdown page with charts. Here's the chart it drew for that run:There isn't much in it, because there isn't much in the experiment. One sandbox starts, writes its greeting and exits 0. The first second goes to setup: the preflight checks run, and the adapter's observers start (more on those below). Then the flow starts the
helloworkload. Creating its sandbox withopenshell sandbox createtakes about a second, sohello-0shows up at second 2. By the time the harness first asks how it's doing, the script is already done. (The axis labels repeat because the whole flow fits in two seconds.)Observers record what the system does while the flow runs, apart from what the workloads write themselves. They start before the flow and stop after it. They come from the adapter, so every experiment on it gets them. The OpenShell Docker adapter has three:
/metricsendpoint and appends the scrape tosystem/metrics/gateway.jsonl. Each family's type goes ingateway.types.json. This run has 16 scrapes of 27 families. By the second one the gateway had handled 38 gRPC requests:CreateSandbox, sixGetSandbox,ConnectSupervisor,DeleteSandboxand so on, each counted by method and status code.docker eventsfor this run's containers and saves them tosystem/watch/docker-events.jsonl.system/watch/otel.jsonl.A watcher is just a command. The harness timestamps every line it prints and saves it, so anything you can tail can be a watcher.
Scraping every 2 seconds is a sample, not a recording. Take
openshell_server_supervisor_sessions, the number of sandboxes with a connected supervisor. It reads 0 in all 16 scrapes of this run, because the sandbox came and went between two of them. In a longer experiment it follows the sandbox count.The scraper keeps every family the gateway exports. An experiment can also list the few it's about under
metrics:. The report draws those first, and the Grafana dashboard draws only those.metricchecks can assert on them, like "the gateway's database readiness probe passed in the last scrape". You'll see one in the next experiment.What came out
Start with the check lines above. Your two checks are in there:
hello-0/exited-okandhello-0/said-hello. So are a few you didn't write. Preflight checks run before anything else and make sure the machine can run an experiment at all. Adapter checks run after every experiment. They catch the stuff nobody thinks to check for: a leaked container, a paused process, an unhealthy gateway.Above the check lines, the harness logs each event as it happens:
(I shortened the path and cut a few check events.)
Between
node.finishedand the first check, the adapter waits 30 seconds. That gives the gateway time to finish deleting containers before the adapter looks for leaked ones. That's why a run with two seconds of work takes 33.Everything the run produced lands in one run directory:
greetingholdshello, world..chaos/exit_codeholds0..chaos/attemptsgets a line every time the script starts. Here there's one.timeline.jsonlhas the same events you saw scroll by, as JSON, withtin seconds since the start:manifest.jsonpins what ran: the experiment's sha256, the OpenShell version, the git sha, Docker version and image digests.system/holds what the observers recorded.Great, a sandbox can write a file. Now let's break something.
A harder one: kill the gateway
There are ten experiments so far:
(
listalso prints each one's title and hypothesis. I cut those.)Some of them reproduce known bugs. You can filter for those:
Let's run the first one.
gateway-kill-keeps-stateasks a simple question. If the gateway dies hard, do the sandboxes it was managing carry on?Three counters tick from 1 to 120, once a second. If something restarts a counter, it picks up from its last saved tick. A journal with ticks 1 to 120, each once, means nothing was lost or doubled. One attempt means nothing restarted it behind our back.
Here's the part of the YAML that breaks things:
An operation is something the experiment does to the system during the flow. You declare it in the experiment. You give it a name and say what it uses and what it acts on: its target.
selectsays which of the target's candidates to hit, and some operations take a duration. Some operations are faults, which break something on purpose. Others just change the system's state, the way an operator would. To the harness they're the same thing.Operations come from two places.
The harness has five built-in faults:
restart,outage,kill,restart-in-placeandfreeze. The name says what happens, and the target decides how. A process gets a signal. A Docker container getsdocker killordocker pause. A Kubernetes workload loses its pods.The adapter can add its own. Each one is a shell hook that makes the change, and usually a second hook that undoes it at the end. The OpenShell adapters have two:
openshell-policy-setchanges a running sandbox's policy.openshell-sandbox-restartstops a sandbox and starts it again.Need one that isn't there? Add it to the adapter. It doesn't have to be a fault.
Unlike a workload's script, an operation doesn't run inside the instances. It runs where the harness runs and reaches into the system from outside. For a process, the harness sends the signal itself. For containers and pods, it runs
dockerorkubectl. An adapter operation is a hook the harness runs on that same machine.Here,
gateway-downusesoutageon thegatewaytarget. In the Docker adapter that target is the gateway process on the host. The harness sends it SIGKILL and keeps it down for 15 seconds. Then the target'sstarthook starts the gateway again and waits until/healthzreturns 200. So the gateway is gone for more than 15 seconds. In this run it was about 18. While it's down the sandboxes keep running, but neither the CLI nor the harness can reach the gateway.The flow says when things happen. It's a tree of
serial,parallel,wait,delayandrepeat. This one says: start the counters. Alongside them, wait until they're running, wait 20 more seconds and take the gateway down. When all that's done, create one fresh sandbox to see whether the restarted gateway can still make new ones.A
waitdoesn't have to wait for a workload. It can also wait for a signal, which is a line showing up in a watcher's output. The adapter names the signal and gives the pattern to look for. The wait ends the next time a line matches, or a set delay after that. The OpenShell Docker adapter has one,reconcile-sweep. It matches the trace span the gateway sends at the end of each reconcile sweep.sandbox-stop-startuses it to aim at a race. A sweep starts 60 seconds after the previous one ended. So the flow waits for a sweep and restarts all the sandboxes 57.5 seconds later, just before the next one. It does that for 20 minutes:That turns a race you'd hit once in a blue moon into one the experiment is likely to hit several times per run. A watcher is any command, so a signal can match anything you can turn into lines. That could be a log file you tail, a container's logs, or a loop that prints when a metric crosses a threshold. There's no built-in condition on metrics, though.
Back to
gateway-kill-keeps-state. I ran it the same way ashello-world:Here are the check results:
(I copied these from
chaos evaluate, which prints the same block aschaos run. I cut counter-1 and counter-2, which look exactly like counter-0.)All three journals stop at 38:
Tick 38 is 12:09:22 UTC. The timeline has the gateway coming back one second later:
So the sandboxes were fine while the gateway was dead. They kept counting the whole time. It's the gateway coming back that killed them.
gateway.logsays why:The restarted gateway starts a new supervisor for each live sandbox. The sandbox runtime rejects it, because the old one is still attached. Then the driver stops the container. That's #4910.
The run took 1m 50s. Here's what it looked like, from
chaos share:The red band in the flow section is the outage. Each counter goes Unknown while the harness can't ask the gateway for its state. It goes back to Running when the gateway returns, then Pending, then Failed.
report.htmlhas the same story plus the metrics:And if you want to dig around,
mise run e2e:chaos:grafana <run dir>loads one or more runs into a local Prometheus and Grafana:Expectations and verdicts
Why XFAIL and not FAIL? Because of this bit:
An expectation ties checks to a known issue. The checks still say what should happen. The expectation says "and right now it doesn't, see this issue". That way the experiment doesn't have to lie about the behavior it wants just to stay green.
There are four verdicts:
Some bugs are races and don't show up every time. Mark an expectation
kind: flakyand the check may fail or pass. Both are fine.sandbox-stop-startuses that for #4981, which triggers in about 1 of 72 starts.Adapters
Apart from the
default-adapterline, nothing in the experiments above mentions Docker. That's the point of the adapter. The harness itself knows nothing about OpenShell, or about sandboxes at all. An adapter binds it to a system that runs workloads. It's a YAML file of hooks. They say how to create an instance, ask its state, collect it and delete it. They also name the fault targets, what to scrape and what to check afterwards. The built-in faults work on processes, containers and Kubernetes pods. So supporting some other system means writing the same kind of file with different hooks.There are two so far. Both are for OpenShell, so an instance is a sandbox:
openshell-dockerruns undere2e/with-docker-gateway.sh. Its targets are thegatewayprocess and thesandboxandsupervisorcontainers. It has the observers and the preflight and adapter checks you saw above. All the runs in this post used it.openshell-kubernetestargets pods. It loads and validates, but I haven't run it against a cluster yet, so no run directory from it exists.Preflight and provisioning
Before an experiment touches anything, the harness runs preflight checks. They make sure the system is there and in a state worth testing. A failed preflight check skips the flow and ends the run with ERROR. That way a broken setup never looks like a failed experiment. You saw the Docker adapter's preflight checks at the start of both runs: Docker answers, the images are there, the CLI runs and the gateway is up. The Kubernetes adapter's checks make sure the API server answers and the gateway has a ready replica. An experiment can add its own, which run after the adapter's. They're shell scripts too.
Preflight also tells you when an experiment can't run on an adapter at all.
sandbox-container-restartrestarts a sandbox's container in place. OpenShell's Kubernetes driver creates sandbox pods withrestartPolicy: Never, which tells Kubernetes to never restart their containers. So on Kubernetes that fault can't happen. Onopenshell-kubernetes,chaos preflightandchaos runreport the experiment asn/aand exit 0 without running anything.Preflight checks that the system exists, but nothing in the harness creates it yet. Today the wrappers do.
e2e/with-docker-gateway.shbuilds and starts the gateway, ande2e/with-kube-gateway.shinstalls the chart into a cluster. The plan is to give adapters aprovisionand adeprovisionhook. Say you want to run experiments on a real Kubernetes cluster in the cloud.chaos provisionruns the adapter's hook, which uses Terraform to create the cluster and installs the gateway on it. If the adapter also manages the machines, Ansible sets them up. Thenchaos runstarts with preflight, which makes sure the cluster is in good shape, and the experiment runs. The experiment itself only says what it needs, and preflight checks it.chaos runnever provisions anything on its own.Mostly shell, on purpose
If you've been reading the YAML closely, you've seen
shella lot. Almost everything you write is a shell script. A workload'srunis a script that runs in each instance. Ashellcheck is a script that runs on the host against the run directory. The adapter is shell too. Its hooks create, poll, collect and delete instances, and start targets back up. Its operations and watchers are scripts as well. The harness runs them, enforces their timeouts and records what happened. It doesn't need to know what they're for.That's deliberate. I didn't want to invent abstractions before I knew which ones I needed. A script can do anything, so a new kind of experiment doesn't have to wait for a new feature. When the same script keeps showing up, it can become its own kind. The few things that aren't shell today are the ones that were worth a name:
exit-codeandmetricchecks, and the built-in faults, which have to mean the same thing on Docker and Kubernetes.The run directory is the source of truth
Checks only ever read the run directory. They never talk to the live system. So you can evaluate a run again later, offline, with no gateway around.
That writes a new file under
evaluations/and never touches the originalverdict.json.You can also evaluate an old run with a newer experiment file. I added the
database-healthy-after-restartmetric check after this run, and the old run picked it up:The same goes for the other commands.
report,share,export,dashboardand the Grafana task all read the run directory.chaos --helplists them. Hand someone a run directory and they have everything you had.Try it
The code is in a PR on my fork, on the branch
feat/chaos-experiment-harness/mpereira. To check that your setup works, run:It runs
hello-worldandsmoke, and fails unless both PASS. From there, pick any experiment:Right now the experiments reproduce these issues:
gateway-kill-keeps-state,policy-tighten-survives-gateway-restart)sandbox-container-restart)sandbox startsometimes fails because the reconcile sweep races it (sandbox-stop-start)gateway-sigterm-during-recovery)Feedback welcome
This is just a prototype I built because I was interested in doing fault testing on OpenShell. I'd love to hear what you think of the ideas. Maybe some of them are useful and some aren't.
The important part is the experiment model, not how the prototype implements it. By the model I mean the workloads, operations, flow, checks and expectations, plus the adapter that binds them to a system. The prototype is Python and shell because that was the quickest way to try the idea. A real implementation could just as well sit on top of OpenShell's Rust testkit in
tests/testkit.Here's where I think it could help:
All reactions