Skip to content

fix(target-allocator): tolerate missing ServiceMonitor/PodMonitor CRDs - #394

Open
wenegiemepraise wants to merge 11 commits into
aws:mainfrom
wenegiemepraise:ta-crd-resilience
Open

fix(target-allocator): tolerate missing ServiceMonitor/PodMonitor CRDs#394
wenegiemepraise wants to merge 11 commits into
aws:mainfrom
wenegiemepraise:ta-crd-resilience

Conversation

@wenegiemepraise

@wenegiemepraise wenegiemepraise commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Make the Target Allocator (TA) start and run healthily whether or not the community
monitoring.coreos.com ServiceMonitor/PodMonitor CRDs are installed, and begin/stop
watching each type automatically as its CRD appears/disappears — with no restart, ever.
Built on top of this PR: #386

Problem

The TA consumes the SM/PM CRDs but doesn't require them to exist. Today
Watch() builds the ServiceMonitor and PodMonitor informers unconditionally at
startup and blocks on WaitForNamedCacheSync. If a CRD is absent, the apiserver
rejects LIST/WATCH for that GVR, the informer never syncs, and Watch() returns
failed to sync cache. Because that setup runs once, installing the CRD later
requires a TA restart.

Fix

Make the watcher event-driven and per-type instead of one-shot and all-or-nothing:

  • crdExists — check whether a given CRD is installed before building its informer.
  • startMonitorInformer / stopMonitorInformer — build/tear down a single monitor
    informer on demand (idempotent); stopping drops its targets and signals a reload.
  • watchCRDs — an informer over CustomResourceDefinition objects; on Add starts the
    corresponding monitor informer, on Delete stops it.
  • Watch() rewritten — start informers only for CRDs present at startup; absent ones
    are skipped (TA stays healthy) and started later by the CRD watch. Never returns
    failed to sync cache due to a missing CRD.

Testing

1. Unit (this PR):

  • TestWatchStartsHealthyWithoutCRDs — no CRDs → Watch healthy, no error, no jobs
  • TestWatchStartsInformerWhenCRDCreated — CRD created at runtime → informer starts, no restart
  • TestWatchPerTypeIndependence — SM present, PM absent → only SM informer starts
  • TestStopMonitorInformerDropsType — CRD removed → informer stopped, targets dropped, reload signalled
  • TestCRDObjectName — CRD-name extraction incl. delete tombstone

2. Before/after reproduction (A/B): same condition (CRDs absent) —
pre-fix Watch() returns failed to sync cache (output above); post-fix
TestWatchStartsHealthyWithoutCRDs passes (healthy) and TestWatchStartsInformerWhenCRDCreated
self-heals when the CRD appears.

musa-asad and others added 4 commits June 16, 2026 09:21
…er startup

The target-allocator declared the enable-prometheus-cr-watcher flag name as a
constant but never registered it on the flag set, while the operator passes
--enable-prometheus-cr-watcher whenever PrometheusCR.enabled is true. Because
args are parsed with pflag.ExitOnError, the unregistered flag caused the binary
to print 'unknown flag' and exit(2), putting the target-allocator pod into
CrashLoopBackOff.

This change registers the flag and ORs it with the YAML prometheus_cr.enabled
setting, then fixes three latent defects that were previously unreachable
because the binary crashed first:

- promOperator: set a non-empty Namespace on the synthetic Prometheus object so
  the prometheus-operator config generator no longer panics with
  'namespace can't be empty' in store.ForNamespace.
- promOperator: set EvaluationInterval so the generated config does not render an
  empty global.evaluation_interval, which the prometheus config parser rejects
  with 'empty duration string'.
- main: create and register service-discovery metrics and pass them to
  discovery.NewManager; passing a nil sdMetrics map makes every SD provider fail
  to register, yielding zero discovered targets.

RELEASE_NOTES updated.
Add a regression test asserting that loading a Target Allocator config whose
static scrape job omits scrape_protocols still yields a non-empty
ScrapeProtocols on every loaded scrape config. This is defaulted by the pinned
Prometheus library during yaml.UnmarshalStrict into the prometheus Config type,
so the distributed /scrape_configs payload is never empty and the agent's
prometheus-receiver validation passes. The test fails fast if a future
dependency or load-path change drops this defaulting.
The pod-template restart-trigger sha256 was computed from Spec.Config only,
so a change to Spec.Prometheus (rendered into a separate ConfigMap) left the
pod template byte-identical and the workload controller did not roll the pods.

Fold the serialized Spec.Prometheus (PrometheusConfig.Yaml()) into the hash
input when it is non-empty, so a Prometheus-only change bumps the pod-template
annotation and triggers a rolling restart, matching agent-config behavior.
When no Prometheus config is set the hash input is byte-identical to the agent
config alone, leaving non-Prometheus agents unaffected.
Start each monitor informer lazily, driven by a watch on the
ServiceMonitor/PodMonitor CustomResourceDefinitions, instead of building both
informers eagerly and blocking on cache sync at startup. The Target Allocator
now starts and stays healthy whether or not the CRDs are installed, and begins
(or stops) watching each type automatically as its CRD is created or deleted --
no restart required.

- NewPrometheusCRWatcher no longer builds informers; it adds an apiextensions
  client and empty per-type informer/stop-channel maps.
- startMonitorInformer/stopMonitorInformer manage each type independently
  (fresh factory per start so a deleted-then-recreated CRD restarts cleanly).
- watchCRDs starts/stops informers on CRD add/delete; Watch never fails just
  because a CRD is absent.
- LoadConfig guards against absent informers under a read lock.
- Close stops all per-type informers.

Implicitly gated on prometheusCR.enabled (the watcher is only constructed when
that is set), so behavior is unchanged when CR watching is off. Promotes
k8s.io/apiextensions-apiserver to a direct dependency.
wenegiemepraise and others added 2 commits July 9, 2026 10:49
Add unit tests for the missing branches in the TA CRD-resilience logic:
startMonitorInformer idempotency and unknown-resource error, stopMonitorInformer
no-op when not running, crdExists surfacing non-NotFound errors, Watch
tolerating a CRD-check error, the untracked-CRD add path, and the notify
coalescing handler on add/update/delete.
}, nil
stopCh := make(chan struct{})
informer.Start(stopCh)
if ok := cache.WaitForNamedCacheSync(resourceName, stopCh, informer.HasSynced); !ok {

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startMonitorInformer holds informersMtx across Start and WaitForNamedCacheSync, and stopCh isn't recorded in informerStopChannels yet, so a slow apiserver could block the other start and stop paths and keep Close() from interrupting the sync. Could we run the sync outside the lock and select on w.stopChannel?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right i didnt catch this. I reproduced and fixed the issue. The sync ran under informersMtx and only watched a local stop channel, so a slow/rejected LIST hung Close(). Now Start/WaitForNamedCacheSync run outside the lock and select on w.stopChannel, and Close() signals shutdown before draining. Added a test that blocks the LIST and asserts Close() returns (hangs before, passes after, race-clean).

startMonitorInformer held informersMtx across informer.Start and
WaitForNamedCacheSync, with the sync waiting on a local stop channel not
reachable by Close(). Under a slow or rejected CRD LIST this blocked LoadConfig
for the whole sync and, worse, deadlocked Close() (which needs the same lock)
until SIGKILL.

Run Start/WaitForNamedCacheSync outside the lock and select on w.stopChannel so
Close() always interrupts an in-flight sync; re-check shutdown under the lock
before registering so no live informer is left after Close(). Close() now signals
w.stopChannel before draining, closing the register-after-close race.

Adds TestCloseInterruptsInFlightSync (blocks the ServiceMonitor LIST, asserts
Close() returns) which hangs before the fix. Verified with go test -race.
// existing CRDs on its initial sync, so CRDs present at startup are handled here
// too (startMonitorInformer is idempotent).
func (w *PrometheusCRWatcher) watchCRDs(notifyEvents chan struct{}) {
factory := apiextensionsinformers.NewSharedInformerFactory(w.crdClient, allocatorconfig.DefaultResyncTime)

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

watchCRDs informs on every CRD and caches each full object with its OpenAPI schema, even though only the two names in crdNameToResource matter, so memory could balloon on clusters with lots of CRDs. Could a PartialObjectMetadata informer or a metadata.name selector on those two names work instead?

// Start informers for CRDs that already exist. Absent CRDs are simply
// skipped here; the CRD watch below starts them if/when they appear.
for crdName, resourceName := range crdNameToResource {
exists, err := w.crdExists(context.Background(), crdName)

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

crdExists in the Watch startup loop calls Get with context.Background() and no deadline, so a wedged apiserver could block the loop forever. Could we pass a context with a timeout?

func (w *PrometheusCRWatcher) Close() error {
// Signal shutdown first so any in-flight startMonitorInformer aborts its cache
// sync and will not register a new informer after this point.
close(w.stopChannel)

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close() will panic if it's ever called twice since it closes w.stopChannel unconditionally. Only called once today, but a sync.Once would be cheap insurance.

func (w *PrometheusCRWatcher) watchCRDs(notifyEvents chan struct{}) {
factory := apiextensionsinformers.NewSharedInformerFactory(w.crdClient, allocatorconfig.DefaultResyncTime)
crdInformer := factory.Apiextensions().V1().CustomResourceDefinitions().Informer()
_, _ = crdInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AddEventHandler error is dropped here and in startMonitorInformer, so a failed registration would silently wire up no handler. Could we at least log it so a dead watch is visible?

// LoadConfig no longer emits its targets), and a reload is signalled. This is
// the logic invoked by the CRD watch's DeleteFunc; it is exercised directly so
// the assertion does not depend on fake-clientset delete-watch delivery.
func TestStopMonitorInformerDropsType(t *testing.T) {

@musa-asad musa-asad Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Delete path from crdObjectName to stopMonitorInformer on the CRD informer's DeleteFunc is the one bit not covered end to end. Could we add a test that deletes the CRD via the fake client and asserts the informer stops, or confirm the E2E suite already hits it?

watchCRDs used a full CustomResourceDefinition informer that cached every CRD
object including its OpenAPI v3 schema, though only the two names in
crdNameToResource matter — memory could balloon on clusters with many large
CRDs. Switch to a client-go metadata informer (PartialObjectMetadata), which
caches object metadata only. Add a metadata client to the watcher; crdExists
keeps using the apiextensions client. Tests drive CRD events via the metadata
fake tracker; crdObjectName now handles PartialObjectMetadata.
The Watch startup loop called crdExists with context.Background() and no
deadline, so a wedged apiserver could block startup indefinitely. Give each
check a crdCheckTimeout-bounded context (cancelled per iteration) so a slow
CRD Get fails fast and defers to the CRD watch instead of hanging.
Close() now guards shutdown with sync.Once so a second call is a no-op instead
of panicking on the already-closed stopChannel. The CRD informer's
AddEventHandler error is logged instead of dropped, so a failed registration
(which would silently observe no CRD add/remove) is visible. (startMonitorInformer's
ForResource.AddEventHandler returns no error — the upstream wrapper swallows it.)
Add TestWatchStopsInformerWhenCRDDeleted, driving the CRD watch DeleteFunc via
the metadata fake tracker to assert a deleted CRD stops its informer end to end
(reliable with the metadata informer; the earlier apiextensions delete-watch was
flaky). Add TestCloseIdempotent for the sync.Once guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants