429 chaos experiments across 22 operators: what actually broke


In Part 1 I introduced operator-chaos and ran it against cert-manager. 18 experiments, 18 Resilient verdicts. Clean pass. That was the proof of concept: the framework works, the experiment lifecycle is sound, the verdicts are meaningful.

Then I scaled it up. 22 operators. 429 experiments. CLI mode for all of them, SDK mode (chaos-instrumented images) for 7 RHOAI operators on OpenShift 4.21.

PodKill and NetworkPartition passed almost everywhere, which is expected. Those test Kubernetes recovery, not operator reconciliation. The interesting faults were DeploymentScaleZero, ImageCorrupt, and ResourceDeletion. They exposed gaps that kubectl delete pod will never find.

The SDK testing went further. Building chaos-instrumented operator images let me inject faults at the HTTP transport layer, inside the operator process. This found 4 confirmed bugs and 2 RBAC gaps that CLI testing could not reach. One operator reported Ready=True while silently swallowing errors. Another crashed with a nil pointer dereference under API pressure. These are the kinds of failures that only show up in production, except now I can reproduce them deterministically.

The test matrix

Here’s the full set of operators I tested, with experiment counts and results. CLI mode across the board, running on kind clusters for CNCF operators and OpenShift 4.21 for RHOAI components.

OperatorExperimentsResilientDegradedFailed
cert-manager181800
Prometheus Operator121200
Tekton Pipelines161600
ArgoCD181800
Knative Serving393810
Knative Eventing141400
Istio282800
Kueue242400
Strimzi161510
Spark Operator141211
Flux121200
Crossplane101000
Cilium Operator8800
MetalLB8800
External Secrets121200
Grafana Operator101000
KEDA141400
Jaeger Operator101000
opendatahub-operator656500
odh-model-controller242400
kserve363600
model-registry212100
Total42942531

425 out of 429 Resilient. The 3 Degraded and 1 Failed verdicts are where the story gets interesting.

Finding #1: PodKill passes, DeploymentScaleZero reveals the truth

Every operator passed PodKill. That’s expected. Kubernetes Deployment controller handles pod replacement. The operator doesn’t need to do anything. This is why PodKill is a low-value experiment for operator testing: it validates the scheduler, not the reconciler.

DeploymentScaleZero is the real test. It scales the operator’s Deployment to zero replicas and checks whether the replica count gets restored. This distinguishes between two fundamentally different operator management strategies:

  1. Active spec enforcement: the managing operator compares current state to desired state every reconcile cycle and corrects any drift
  2. Install-and-forget: something (OLM, Helm, a script) creates the Deployment at install time, then nothing ever checks it again

Strimzi passed 15 of 16 experiments but went Degraded on DeploymentScaleZero.

DeploymentScaleZero recovery comparison across operators
Figure 1: DeploymentScaleZero separates operators that actively enforce replica counts from those that rely on the initial install to set them. Strimzi's replicas stayed at zero because OLM does not reconcile CSV-created Deployments.

Here’s the full experiment output:

$ operator-chaos run experiments/strimzi/scale-zero.yaml \
    --knowledge knowledge/strimzi.yaml

[PRE-CHECK] Verifying steady state for strimzi-cluster-operator...
  ✓ strimzi-cluster-operator: Deployment Available (1/1 replicas)

[INJECT] DeploymentScaleZero
  Target: Deployment/strimzi-cluster-operator (namespace: strimzi)
  Action: scale replicas 1 → 0

[OBSERVE] Watching for recovery (timeout: 300s)
  +0s    replicas: 0/0, Available=False
  +10s   replicas: 0/0, Available=False
  +30s   replicas: 0/0, Available=False
  +60s   replicas: 0/0, Available=False
  +120s  replicas: 0/0, Available=False
  +180s  replicas: 0/0, Available=False
  +240s  replicas: 0/0, Available=False
  +300s  replicas: 0/0, Available=False [TIMEOUT]

[POST-CHECK]
  ✗ strimzi-cluster-operator: replicas=0, expected=1
  ✗ Deployment Available=False

[VERDICT] DEGRADED
  Recovery: FAILED (timeout after 300s)
  Deviations:
    - Deployment/strimzi-cluster-operator: spec.replicas=0, expected=1
    - Deployment/strimzi-cluster-operator: Available=False

Replicas stayed at zero for the full 5-minute timeout. No recovery. The operator was offline and nothing brought it back.

The root cause: OLM creates the operator’s Deployment from the CSV (ClusterServiceVersion) at install time, but it does not continuously reconcile the Deployment spec. The CSV stays in Succeeded phase even when the operator has zero running pods. There’s no process watching whether the operator is actually running.

This is not a Strimzi bug specifically. It’s an OLM architecture decision. Any OLM-managed operator that doesn’t have an external supervisor (like a parent operator enforcing replica counts) will exhibit this behavior. The operator gets installed, works fine, and then if anything scales it to zero, it stays dead.

Compare this to cert-manager, Prometheus Operator, ArgoCD, and Tekton. All four actively enforce replica counts through their managing operator or through Deployment controller watches. When DeploymentScaleZero fires, they detect the drift within 10-15 seconds and restore replicas.

The opendatahub-operator passed 65/65 including all DeploymentScaleZero experiments. It reconciles the full spec of every managed Deployment using server-side apply, so any drift in replicas, images, or resource limits gets corrected on the next cycle.

Finding #2: How a network partition permanently killed the Spark Operator

The Spark Operator was the only operator that received a Failed verdict. Not on PodKill (passed), not on DeploymentScaleZero (passed). On NetworkPartition.

Zombie controller state diagram showing how network partition causes permanent failure
Figure 2: The zombie controller problem: after a network partition lifts, the operator appears alive (liveness probe passes) but its informer cache is stale. It processes no events and reconciles nothing.

Here’s what happened:

$ operator-chaos run experiments/spark/network-partition.yaml \
    --knowledge knowledge/spark.yaml

[PRE-CHECK] Verifying steady state for spark-operator...
  ✓ spark-operator: Deployment Available (1/1 replicas)

[INJECT] NetworkPartition
  Target: pods matching app.kubernetes.io/name=spark-operator
  Action: deny-all NetworkPolicy (ingress + egress)
  TTL: 60s

[OBSERVE] Watching for recovery (timeout: 300s)
  +0s    NetworkPolicy applied, partition active
  +5s    Available=True (pod still running, liveness passes)
  +15s   Available=True (kubelet sees healthy container)
  +30s   Available=True (no restart triggered)
  +60s   NetworkPolicy TTL expired, partition lifted
  +65s   Available=True (pod still running)
  +90s   Available=True, but 0 reconcile events in last 30s
  +120s  Available=True, 0 reconcile events in last 60s
  +180s  Available=False (readiness probe failing)
  +240s  Available=False, still no restart
  +300s  Available=False [TIMEOUT]

[POST-CHECK]
  ✗ spark-operator: 0 reconcile events since partition lift
  ✗ Deployment Available=False

[VERDICT] FAILED
  Recovery: FAILED (timeout after 300s)
  Deviations:
    - spark-operator: no reconcile activity post-partition
    - Deployment/spark-operator: Available=False
  Root cause: informer cache stale, liveness probe does not
    check cache sync status

This is the “zombie controller” problem. During the network partition, the operator’s informer cache loses its WATCH connection to the API server. When the partition lifts, the informer should reconnect and re-list. But the liveness probe only checks whether the HTTP health endpoint responds, not whether the informer cache is actually synced.

Kubelet sees a healthy container (liveness passes), so it never restarts the pod. Readiness failures eventually cause the pod to stop receiving traffic, but readiness failures alone do not trigger a pod restart. The operator sits there, appearing alive, processing nothing.

The fix is straightforward: wire the liveness probe to the informer cache sync status.

mgr.AddHealthzCheck("informer-sync", func(req *http.Request) error {
    if !mgr.GetCache().WaitForCacheSync(req.Context()) {
        return fmt.Errorf("informer cache not synced")
    }
    return nil
})

With this check, a stale informer cache causes the liveness probe to fail, kubelet restarts the pod, and the operator recovers with a fresh cache. Simple fix, but you’d never find it without a NetworkPartition experiment. kubectl delete pod would just restart the pod with a clean state, completely masking the bug.

Finding #3: SDK testing finds bugs CLI cannot reach

CLI mode is black-box testing. You inject faults externally (delete a pod, scale a Deployment, create a NetworkPolicy) and observe whether the operator recovers. This is valuable but limited. You can only test faults that manifest as cluster state changes.

SDK mode is white-box testing. You build a chaos-instrumented operator image with the chaostransport module injecting faults at the HTTP transport layer. Every API call the operator makes, including informer LIST/WATCH, leader election, and CRUD operations, can fail with controlled error rates.

CLI vs SDK testing comparison showing what each approach can and cannot find
Figure 3: CLI testing (black-box) catches resource-level failures. SDK testing (white-box) catches code-level failures: unhandled errors, nil pointer dereferences, silent swallowing of API errors.
chaostransport architecture showing HTTP transport wrapping
Figure 4: The chaostransport module wraps the HTTP transport layer between the operator and the API server. It intercepts every request and can inject errors, latency, or throttling based on operation type and resource.

I built chaos-instrumented images for 7 RHOAI operators running on OpenShift 4.21 with ODH v3.5.0-ea.1. The fault configuration: 40% failure rate on update/patch operations, 20% on create/delete, running for 20+ minutes each. This simulates sustained API server pressure, which is exactly what happens during cluster upgrades, etcd leader elections, or noisy-neighbor situations.

SDK results

OperatorControllersVerdictIssues found
opendatahub-operator15ResilientNone (retry logic covers all paths)
kserve1ResilientNone
model-registry1ResilientNone
trustyai1ResilientNone
spark-operator1ResilientNone (under SDK faults, separate from CLI NetworkPartition finding)
odh-model-controller3Failednil pointer panic in FindSupportingRuntimeForISvc
training-operator1Degradedcert-rotator cache sync timeout

Six issues total across the 7 operators:

  1. odh-model-controller: nil pointer panic. FindSupportingRuntimeForISvc assumed a List call always returns results. Under 40% failure rate, the List returned an error, the result was nil, and the next line dereferenced it. Classic missing nil check.

  2. training-operator: cert-rotator timeout. The cert rotation goroutine uses a fixed timeout for cache sync. Under API pressure with 20% create failures, the sync took longer than the hardcoded deadline. The goroutine exited, leaving the operator without valid certs for webhook serving.

  3. DSC controller: Ready=True while failing. The DSC (DataScienceCluster) controller in opendatahub-operator reported Ready=True on its status even when individual component deploy actions were returning errors. The action pipeline was catching errors but not propagating them to the status condition. This was the most important find because it means the operator could be silently broken while the status looks healthy. Fix merged in ea.2.

  4. DSPO: nil pointer panic in ExtractParams. Under API pressure, a Get call for a ConfigMap returned an error, but ExtractParams used the result without checking the error first. Same pattern as the odh-model-controller bug.

  5. Two CSV RBAC gaps. Two operators were missing RBAC permissions in their ClusterServiceVersion that only manifested under retry conditions. When a create operation failed and the operator retried, it needed update permission on a resource it only had create for. Both fixed in ea.2.

None of these would have been found by CLI testing. The CLI can delete a pod, but it can’t make 40% of PATCH operations return a 409 Conflict. That’s the gap SDK testing fills.

Finding #4: The dependency conflict problem

When I started building chaos-instrumented images, I hit dependency conflicts immediately. The chaostransport module needs to be imported into each operator’s codebase. 4 of the 7 operators had incompatible k8s.io versions.

The main operator-chaos SDK pulls in controller-runtime v0.19.7+ and k8s.io/client-go v0.35.x. Operators pinned to v0.18 or earlier can’t import it without a major dependency upgrade, which is not something you want to do just to run chaos tests.

This is why chaostransport exists as a separate Go module with zero external dependencies:

module github.com/opendatahub-io/operator-chaos/pkg/chaostransport

go 1.22.0

That’s the entire go.mod. No controller-runtime. No k8s.io. Only standard library. It works because the transport layer is pure HTTP. The WrapTransport field on rest.Config accepts any function that wraps http.RoundTripper, which is a standard library interface.

For trustyai, even the sub-module import path caused issues with their vendoring setup. The solution: I inlined about 250 lines of the transport wrapper directly into their source tree. No import, no dependency change, same behavior. It’s not elegant, but it works on every operator regardless of their dependency graph.

Three patterns that predict resilience

After running 429 experiments, some patterns emerged. The operators that passed everything share three characteristics. The ones that didn’t are missing at least one.

Three resilience patterns: spec enforcement, operational health checks, resource graph awareness
Figure 5: The three patterns that predict operator resilience: full spec enforcement on every reconcile cycle, health checks that reflect operational state (not just process health), and resource graph awareness that watches all managed resources.

Pattern 1: Full spec enforcement

Resilient operators compare the current state of every managed resource against the desired state on every reconcile cycle, then correct any drift. They don’t just check whether the resource exists. They verify the full spec.

The opendatahub-operator uses server-side apply for this. Every reconcile cycle applies the full desired spec, which means any drift in replicas, images, labels, annotations, or resource limits gets corrected automatically. It doesn’t need special code for each field because SSA handles the merge.

Operators that use CreateOrUpdate with partial patches are more fragile. They only update the fields they explicitly set, which means drift in unmanaged fields goes undetected.

Pattern 2: Health checks reflecting operational state

The zombie controller problem with Spark shows what happens when health checks only test process health (is the HTTP server responding?) instead of operational state (is the informer cache synced? is the leader election lease held? are reconcile events flowing?).

cert-manager’s liveness probe checks informer sync status. The opendatahub-operator checks both informer sync and leader election. These operators recover from network partitions because a stale state triggers a restart.

Pattern 3: Resource graph awareness

Resilient operators watch all resources in their managed graph, not just the top-level CRDs. When a ConfigMap, ServiceAccount, or RBAC binding gets deleted, the operator detects it immediately through its watch and reconciles.

Operators that only watch CRDs miss drift in supporting resources. They’ll notice if you delete a custom resource, but deleting the ServiceAccount that the custom resource’s pod needs? That goes undetected until something crashes at runtime.

In controller-runtime terms, this means using Owns() or Watches() for every resource type the operator manages, not just For() on the primary CRD.

How to test your operator

You don’t need 429 experiments to get value from chaos testing. Start with these four and you’ll cover the most common failure modes.

Test 1: DeploymentScaleZero

The most revealing single experiment. Does anything enforce replica counts?

operator-chaos init \
    --component my-controller \
    --operator my-operator \
    --type DeploymentScaleZero > scale-zero.yaml

operator-chaos run scale-zero.yaml --knowledge knowledge.yaml

If this returns Degraded or Failed, your operator has no supervisor enforcing its replica count. For OLM-managed operators, consider adding a health check controller or using the spec.install.spec.deployments[].spec.replicas enforcement in OLM 1.0+.

Test 2: ResourceDeletion on a supporting resource

Delete a ServiceAccount, ConfigMap, or Secret that the operator created. Does the operator recreate it?

operator-chaos init \
    --component my-controller \
    --operator my-operator \
    --type ResourceDeletion \
    --target "v1/ServiceAccount/my-controller-sa" > sa-delete.yaml

operator-chaos run sa-delete.yaml --knowledge knowledge.yaml

Test 3: NetworkPartition

Create a deny-all NetworkPolicy and check whether the operator recovers after it’s removed.

operator-chaos init \
    --component my-controller \
    --operator my-operator \
    --type NetworkPartition > net-partition.yaml

operator-chaos run net-partition.yaml --knowledge knowledge.yaml

If this returns Failed with zero reconcile events post-partition, your operator has the zombie controller problem. Wire the liveness probe to informer cache sync.

Test 4: ImageCorrupt

Patch the operator’s container image to a nonexistent registry. Does the managing operator detect ImagePullBackOff and report it in status?

operator-chaos init \
    --component my-controller \
    --operator my-operator \
    --type ImageCorrupt > image-corrupt.yaml

operator-chaos run image-corrupt.yaml --knowledge knowledge.yaml

How to fix what you find

Fix: DeploymentScaleZero (Degraded)

If your operator is OLM-managed and nothing enforces replica counts, add a reconcile check in the parent operator or add a controller that watches the operator’s Deployment:

// In your parent operator's reconciler:
func (r *Reconciler) ensureOperatorRunning(ctx context.Context) error {
    dep := &appsv1.Deployment{}
    err := r.Client.Get(ctx, types.NamespacedName{
        Name:      "my-operator",
        Namespace: "my-operator-system",
    }, dep)
    if err != nil {
        return fmt.Errorf("get operator deployment: %w", err)
    }

    if dep.Spec.Replicas == nil || *dep.Spec.Replicas == 0 {
        one := int32(1)
        dep.Spec.Replicas = &one
        if err := r.Client.Update(ctx, dep); err != nil {
            return fmt.Errorf("restore operator replicas: %w", err)
        }
    }
    return nil
}

Or better, use server-side apply with the full desired Deployment spec so all fields get enforced, not just replicas.

Fix: NetworkPartition (Failed / zombie controller)

Wire liveness to informer cache sync:

mgr.AddHealthzCheck("informer-sync", func(req *http.Request) error {
    if !mgr.GetCache().WaitForCacheSync(req.Context()) {
        return fmt.Errorf("informer cache not synced")
    }
    return nil
})

mgr.AddHealthzCheck("leader-election", healthz.LeaderElectionHealthzAdaptor(
    time.Second * 20,
))

Fix: SDK bugs (nil pointer panics, silent error swallowing)

These are straightforward code fixes. The pattern is always the same: check the error before using the result.

// Before (buggy):
list := &SomeList{}
r.Client.List(ctx, list, ...)
// uses list.Items without checking error

// After (fixed):
list := &SomeList{}
if err := r.Client.List(ctx, list, ...); err != nil {
    return fmt.Errorf("list resources: %w", err)
}
// now safe to use list.Items

For the silent error swallowing pattern (DSC controller reporting Ready=True while actions fail), make sure error propagation flows from individual actions up to the status condition:

var errs []error
for _, action := range actions {
    if err := action.Execute(ctx); err != nil {
        errs = append(errs, err)
    }
}
if len(errs) > 0 {
    meta.SetStatusCondition(&dsc.Status.Conditions, metav1.Condition{
        Type:    "Ready",
        Status:  metav1.ConditionFalse,
        Reason:  "ActionsFailed",
        Message: multierror.Append(nil, errs...).Error(),
    })
}

Combined results: CLI + SDK

Here’s the full picture across both testing modes.

OperatorCLI ExperimentsCLI ResultSDK TestSDK ResultIssues
cert-manager1818/18 ResilientN/AN/A0
Prometheus Operator1212/12 ResilientN/AN/A0
Tekton Pipelines1616/16 ResilientN/AN/A0
ArgoCD1818/18 ResilientN/AN/A0
Knative Serving3938/39N/AN/A1 Degraded
Knative Eventing1414/14 ResilientN/AN/A0
Istio2828/28 ResilientN/AN/A0
Kueue2424/24 ResilientN/AN/A0
Strimzi1615/16N/AN/A1 Degraded (scale-zero)
Spark Operator1412/14SDKResilient1 Degraded, 1 Failed (zombie)
opendatahub-operator6565/65 ResilientSDKResilient1 (DSC status bug, fixed)
odh-model-controller2424/24 ResilientSDKFailed1 nil pointer panic
kserve3636/36 ResilientSDKResilient0
model-registry2121/21 ResilientSDKResilient0
trustyaiN/AN/ASDKResilient0
training-operatorN/AN/ASDKDegraded1 cert-rotator timeout
DSPON/AN/ASDKFailed1 nil pointer panic

The takeaway: CLI and SDK testing are complementary, not interchangeable. CLI testing validates reconciliation semantics (does the operator restore state?). SDK testing validates error handling (does the operator crash under API pressure?). You need both.

This is Part 2 of the operator-chaos series. Part 1 covers the framework design, knowledge models, and the cert-manager demo:

Other posts in this series:

Also published on Red Hat Developer:

The full source, documentation, and 25 built-in knowledge models are at github.com/opendatahub-io/operator-chaos.

If you maintain an operator and want to start testing, grab the four experiments from the “How to test your operator” section above. They take 10 minutes to set up, and the DeploymentScaleZero result alone will tell you more about your operator’s resilience than any amount of kubectl delete pod.