Skip to content

fix(kubernetes): informer tombstones panic the deployment/statefulset watchers#1018

Merged
acouvreur merged 1 commit into
mainfrom
fix/kubernetes-informer-tombstone
Jul 11, 2026
Merged

fix(kubernetes): informer tombstones panic the deployment/statefulset watchers#1018
acouvreur merged 1 commit into
mainfrom
fix/kubernetes-informer-tombstone

Conversation

@acouvreur

Copy link
Copy Markdown
Member

Finding

The deployment and statefulset informer handlers assert event objects unchecked:

d := obj.(*appsv1.Deployment)          // AddFunc / DeleteFunc
newDeployment := new.(*appsv1.Deployment)  // UpdateFunc

client-go delivers cache.DeletedFinalStateUnknown tombstones to DeleteFunc whenever a watch disconnect made the informer miss the final delete (it finds out about the deletion from the relist). On a tombstone, the unchecked assertion panics inside the informer's processor goroutine - there is no HTTP recovery middleware there, so the panic kills the entire Sablier process.

The trigger is mundane: any API-server hiccup / network blip that drops the watch while a managed workload gets deleted. Exactly the moment you want Sablier to stay up.

Two adjacent latent panics in the same handlers:

  • UpdateFunc dereferences *oldDeployment.Spec.Replicas without a nil check - the field is a *int32.
  • The same unchecked assertions exist in AddFunc/UpdateFunc (tombstones are delete-only in practice, but the assertions are equally unguarded against anything unexpected).

The telling part

The CNPG watcher in the same package already solves this - clusterFromObject unwraps tombstones correctly (cnpgcluster_events.go). The newest of the three watchers got the fix; the two older copies never did. Classic copy-paste divergence.

Discovery

Found during a provider-layer design review (cross-comparing the three informer watchers). Reproduced by feeding a tombstone through the real handlers (extracted as named methods for testability - a behavior-preserving refactor):

--- FAIL: TestDeploymentDeleteHandlesTombstone (0.00s)
--- FAIL: TestStatefulSetDeleteHandlesTombstone (0.00s)
--- FAIL: TestUpdateHandlesNilReplicas (0.00s)
panic: interface conversion: interface {} is cache.DeletedFinalStateUnknown, not *v1.StatefulSet

Fix

  • New generic eventObject[T any](obj any) (T, bool) helper: returns the typed object, unwrapping a tombstone when present, and refuses anything unexpected instead of panicking. All three watchers (deployment, statefulset, CNPG) now use one mechanism - clusterFromObject delegates to it.
  • replicasOf(*int32) guards the Spec.Replicas dereferences, defaulting to 1 exactly like the API server.
  • watchDeployments / watchStatefulSets now build their handlers via named methods (deploymentEventHandler, statefulSetEventHandler) so the handlers are unit-testable; informer wiring is unchanged.

Tests

  • TestDeploymentDeleteHandlesTombstone / TestStatefulSetDeleteHandlesTombstone - panic before, emit the expected Removed + Stopped events after.
  • TestUpdateHandlesNilReplicas - pins the nil-Spec.Replicas guard.

Package tests and golangci-lint pass.

Docs

No user-facing docs impact (internal crash fix; no config, label, or API change).

… watchers

The deployment and statefulset event handlers asserted event objects
unchecked (obj.(*appsv1.Deployment)). Informers deliver
cache.DeletedFinalStateUnknown tombstones to DeleteFunc when a watch
disconnect made them miss the final delete; the assertion then panics inside
the informer processor goroutine and kills the whole process. The CNPG
watcher already unwrapped tombstones (clusterFromObject); the two older
watchers never got the same fix.

All handlers now go through a shared generic eventObject[T] helper that
unwraps tombstones and refuses unexpected types, and clusterFromObject
delegates to it. UpdateFunc additionally guarded: Spec.Replicas is a *int32
and was dereferenced without a nil check; replicasOf defaults it to 1 like
the API server does.

Reproduced by feeding tombstones through the real handlers (extracted as
named methods for testability): interface conversion panic before, clean
Removed+Stopped events after.
@github-actions github-actions Bot added the provider Issue related to a provider label Jul 11, 2026
@github-actions

Copy link
Copy Markdown
┌──────────────────────────────────────────────────────────────────────────────┐
│ Diff between sablier and sablier                                             │
├─────────┬────────────────────────────────────┬──────────┬──────────┬─────────┤
│ PERCENT │ NAME                               │ OLD SIZE │ NEW SIZE │ DIFF    │
├─────────┼────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.28%  │ go.opentelemetry.io/otel           │ 1.1 MB   │ 1.1 MB   │ +3.0 kB │
│ +0.08%  │ github.com/sablierapp/sablier      │ 654 kB   │ 655 kB   │ +550 B  │
│ +0.00%  │ github.com/google/gnostic-models   │ 1.6 MB   │ 1.6 MB   │ +13 B   │
│ +0.01%  │ github.com/spf13/viper             │ 73 kB    │ 73 kB    │ +7 B    │
│ +0.00%  │ crypto                             │ 1.9 MB   │ 1.9 MB   │ +1 B    │
│ +0.00%  │ google.golang.org/grpc             │ 1.2 MB   │ 1.2 MB   │ +1 B    │
│ +0.00%  │ text/template                      │ 293 kB   │ 293 kB   │ +1 B    │
│ +0.00%  │ github.com/emicklei/go-restful/v3  │ 134 kB   │ 134 kB   │ +1 B    │
│ -0.00%  │ k8s.io/api                         │ 17 MB    │ 17 MB    │ -1 B    │
│ -0.00%  │ net                                │ 1.7 MB   │ 1.7 MB   │ -1 B    │
│ -0.00%  │ github.com/luthermonson/go-proxmox │ 333 kB   │ 333 kB   │ -3 B    │
│ -0.00%  │ k8s.io/client-go                   │ 14 MB    │ 14 MB    │ -16 B   │
│ -0.02%  │ github.com/quic-go/quic-go         │ 1.4 MB   │ 1.3 MB   │ -283 B  │
├─────────┼────────────────────────────────────┼──────────┼──────────┼─────────┤
│ -0.00%  │ .rodata                            │ 2.6 MB   │ 2.6 MB   │ -57 B   │
├─────────┼────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.00%  │ sablier                            │ 62 MB    │ 62 MB    │ +0 B    │
│         │ sablier                            │          │          │         │
└─────────┴────────────────────────────────────┴──────────┴──────────┴─────────┘

@github-actions

Copy link
Copy Markdown

Test Results

✅ All tests passed! | 757 tests in 145.822s

⚠️ 1 test(s) were flaky (failed then passed on rerun)

  • github.com/sablierapp/sablier/pkg/provider/dockerswarm/TestDockerSwarmProvider_InstanceEvents_Created
    View HTML Test Report

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
78.4% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@acouvreur acouvreur merged commit 8b93854 into main Jul 11, 2026
7 of 8 checks passed
@acouvreur acouvreur deleted the fix/kubernetes-informer-tombstone branch July 11, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

provider Issue related to a provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant