package k8score import ( "cmp" "context" "errors" "fmt" "log" "maps" "runtime" "slices" "sort" "strings" "sync" "sync/atomic" "time" "golang.org/x/sync/singleflight" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/tools/cache" ) var ErrResourceNotFound = errors.New("resource not found") var ErrResourceCountUnavailable = errors.New("resource count unavailable") var ErrCollectionNotSupported = errors.New("collection is not supported") const directCountProbeLimit int64 = 2 var scopeProbeBaseBudget = 5 * time.Second // deniedScopeTTL is how long a complete refusal answers later reads of the same // scope. Without it every read of a refused kind repeats the walk, one // cluster-wide check plus one per candidate namespace, and a page reading a // dozen such kinds pays that dozen times on every poll. It is also how long a // new grant can go unnoticed. const deniedScopeTTL = 60 * time.Second // informerKey identifies one informer. ns == "" means a cluster-wide watch; // a non-empty ns is a namespace-scoped watch. A GVR can have one cluster-wide // informer OR several namespace-scoped ones (for users denied cluster-wide // list but allowed in specific namespaces) — never both, since the // cluster-wide informer already covers every namespace. type informerKey struct { gvr schema.GroupVersionResource ns string } // informerEntry is one running informer plus its lifecycle handle. synced is // guarded by DynamicResourceCache.mu. type informerEntry struct { informer cache.SharedIndexInformer cancel context.CancelFunc synced bool startedAt time.Time } type retainedObservation struct { observedAt time.Time state DynamicObservationState reasonCode string truncated bool } // scopeDenial is a complete refusal: every check of the walk was answered and // every answer was Forbidden. type scopeDenial struct { at time.Time err error } // DynamicResourceCache provides on-demand caching for CRDs and other dynamic // resources. It is safe for concurrent use. Application-specific callbacks // (timeline, metrics) are injected via DynamicCacheConfig. type DynamicResourceCache struct { factory dynamicinformer.DynamicSharedInformerFactory nsFactories map[string]dynamicinformer.DynamicSharedInformerFactory // one per watched namespace, lazily created informers map[informerKey]*informerEntry informerGeneration uint64 // fallbackResolved marks GVRs whose "all namespaces" scope was already // probed and fanned out across the fallback namespaces. Without it every // all-namespaces read of a fallback-scoped GVR would re-probe cluster-wide // plus each candidate namespace. Guarded by mu. fallbackResolved map[schema.GroupVersionResource]bool // observations retain watch decisions and probe outcomes for installed GVRs // that do not currently have an informer. Active informers take precedence. observations map[schema.GroupVersionResource]retainedObservation // deniedScopes holds recent complete refusals by (gvr, preferred namespace), // which is the scope a walk answered for: a walk for all namespaces says // nothing about a namespace outside its candidates. The cache belongs to // one identity, so an entry never outlives it. Guarded by mu. deniedScopes map[informerKey]scopeDenial // fanoutIncomplete records that a namespace fallback walk ended before all // configured candidates were classified. Guarded by mu. fanoutIncomplete map[schema.GroupVersionResource]bool // probeRetries tracks access checks that got no answer, which the cache // re-runs itself on a bounded schedule. Guarded by mu. probeRetries map[schema.GroupVersionResource]*probeRetry probeSlots chan struct{} probeCtx context.Context stopProbes context.CancelFunc stopCh chan struct{} // global shutdown; parent of every per-informer context stopOnce sync.Once stopped bool // set under mu by Stop; startWatching refuses new informers after mu sync.RWMutex config DynamicCacheConfig discoveryStatus CRDDiscoveryStatus discoveryMu sync.RWMutex discoveryDone chan struct{} // closed when DiscoverAllCRDs() completes // gvrHandlers holds change handlers registered via AddGVRChangeHandler, // keyed by GVR. They are re-applied to every informer started for that GVR // — including namespace-scoped informers created lazily after registration // — so derived caches keep receiving events. Guarded by mu. gvrHandlers map[schema.GroupVersionResource][]cache.ResourceEventHandler // watchStarts collapses concurrent ensureWatching calls for the same // (gvr, ns) into one probe + informer start. The access probe runs // outside d.mu, so without this a parallel fan-out over N objects of an // unwatched kind would issue N redundant limit=1 list probes before one // caller wins startWatching. watchStarts singleflight.Group // CRD discovery completion callbacks crdCallbacks []func() crdCallbacksMu sync.RWMutex // CRDs installed after startup discovery. seenCRDs holds the group/resource // of every CRD that already got a watch decision; pendingCRDs the new ones // waiting for a scan. Guarded by lateCRDMu. seenCRDs map[string]struct{} pendingCRDs map[string]struct{} lateCRDTimer *time.Timer lateCRDScanning bool lateCRDRetries int lateCRDMu sync.Mutex } // NewDynamicResourceCache creates a dynamic resource cache with the given config. func NewDynamicResourceCache(cfg DynamicCacheConfig) (*DynamicResourceCache, error) { if cfg.DynamicClient == nil { return nil, fmt.Errorf("dynamic client must not be nil") } if cfg.NamespaceScoped && cfg.Namespace == "" { return nil, fmt.Errorf("namespace must be set when NamespaceScoped is true") } factory := dynamicinformer.NewDynamicSharedInformerFactory( cfg.DynamicClient, 0, // no resync — updates come via watch ) if cfg.NamespaceScoped && cfg.Namespace != "" { log.Printf("Using namespace-scoped dynamic informers for namespace %q", cfg.Namespace) } else if len(cfg.NamespaceFallbacks) > 0 { log.Printf("Using namespace fallbacks for dynamic informers: %q", cfg.NamespaceFallbacks) } else if cfg.NamespaceFallback != "" { log.Printf("Using namespace fallback for dynamic informers: %q", cfg.NamespaceFallback) } probeCtx, stopProbes := context.WithCancel(context.Background()) d := &DynamicResourceCache{ factory: factory, nsFactories: make(map[string]dynamicinformer.DynamicSharedInformerFactory), fallbackResolved: make(map[schema.GroupVersionResource]bool), observations: make(map[schema.GroupVersionResource]retainedObservation), deniedScopes: make(map[informerKey]scopeDenial), fanoutIncomplete: make(map[schema.GroupVersionResource]bool), probeRetries: make(map[schema.GroupVersionResource]*probeRetry), probeCtx: probeCtx, probeSlots: make(chan struct{}, 50), stopProbes: stopProbes, informers: make(map[informerKey]*informerEntry), stopCh: make(chan struct{}), config: cfg, discoveryStatus: CRDDiscoveryIdle, discoveryDone: make(chan struct{}), gvrHandlers: make(map[schema.GroupVersionResource][]cache.ResourceEventHandler), seenCRDs: make(map[string]struct{}), pendingCRDs: make(map[string]struct{}), } log.Println("Dynamic resource cache initialized") return d, nil } func (d *DynamicResourceCache) retainObservation(gvr schema.GroupVersionResource, state DynamicObservationState, reasonCode string, truncated bool) { d.mu.Lock() defer d.mu.Unlock() delete(d.deniedScopes, informerKey{gvr: gvr}) d.observations[gvr] = retainedObservation{state: state, reasonCode: reasonCode, truncated: truncated, observedAt: time.Now().UTC()} } // retainAuthProbeFailure records an access probe that failed authentication // or authorization, and reports whether err was one. A 401 rejects Radar's // credentials, which no grant on this kind fixes, so it is recorded apart from // an RBAC denial. func (d *DynamicResourceCache) retainAuthProbeFailure(gvr schema.GroupVersionResource, preferredNS string, complete bool, err error) bool { switch { case apierrors.IsUnauthorized(err): d.retainObservation(gvr, DynamicObservationDenied, "credentials_rejected", false) case isAuthProbeError(err): d.retainDeniedObservation(gvr, preferredNS, complete, err) default: return false } return true } func (d *DynamicResourceCache) retainDeniedObservation(gvr schema.GroupVersionResource, preferredNS string, complete bool, err error) { // A preferred-namespace denial proves only that request scope; it cannot // replace the observation retained for the GVR as a whole. if preferredNS != "" { if complete { d.rememberDenial(gvr, preferredNS, err) } return } if !complete { d.retainObservation(gvr, DynamicObservationDeferred, "scope_probe_incomplete", true) return } truncated := d.namespaceProbeTruncated(gvr, preferredNS, complete) reasonCode := "access_denied" if truncated { reasonCode = "access_denied_partial_probe" } d.retainObservation(gvr, DynamicObservationDenied, reasonCode, truncated) d.rememberDenial(gvr, "", err) } func (d *DynamicResourceCache) rememberDenial(gvr schema.GroupVersionResource, preferredNS string, err error) { now := time.Now() d.mu.Lock() defer d.mu.Unlock() maps.DeleteFunc(d.deniedScopes, func(_ informerKey, denial scopeDenial) bool { return now.Sub(denial.at) >= deniedScopeTTL }) d.deniedScopes[informerKey{gvr: gvr, ns: preferredNS}] = scopeDenial{at: now, err: err} } // recentDenial returns the Forbidden a walk of this scope ended with, if it // did within deniedScopeTTL. func (d *DynamicResourceCache) recentDenial(gvr schema.GroupVersionResource, preferredNS string) error { d.mu.RLock() defer d.mu.RUnlock() denial, ok := d.deniedScopes[informerKey{gvr: gvr, ns: preferredNS}] if !ok || d.stopped || time.Since(denial.at) >= deniedScopeTTL { return nil } return noAccessError(gvr, denial.err) } func noAccessError(gvr schema.GroupVersionResource, err error) error { return fmt.Errorf("no access to %s.%s/%s: %w", gvr.Resource, gvr.Group, gvr.Version, err) } // Observation returns the current observation contract for one exact GVR. // It only reads cache bookkeeping; it never probes the API server, creates an // informer, or waits for sync. func (d *DynamicResourceCache) Observation(gvr schema.GroupVersionResource) DynamicResourceObservation { return d.observation(gvr, "") } // NamespaceObservation is Observation with State, and the loading or stall // ReasonCode, taken only from the informer that serves reads of gvr in // namespace: the cluster-wide informer when there is one, otherwise // namespace's own. Per-namespace informers sync independently, so a // namespace's readiness never reflects an informer started for another // namespace, which in a shared cache may be another user's. Scope, Namespaces // and Truncated still describe the whole kind, and so does a synced // namespace's ReasonCode (such as namespace_partial). When the kind has informers but none serves namespace, State is // DynamicObservationUnwatched. An empty namespace, or a kind with no // informers, gets Observation(gvr). func (d *DynamicResourceCache) NamespaceObservation(gvr schema.GroupVersionResource, namespace string) DynamicResourceObservation { return d.observation(gvr, namespace) } // syncStallAfter is how long an informer may stay unsynced before its // observation reports sync_stalled. func (d *DynamicResourceCache) syncStallAfter() time.Duration { if d.config.SyncStallAfter > 0 { return d.config.SyncStallAfter } return 30 * time.Second } func (d *DynamicResourceCache) observation(gvr schema.GroupVersionResource, namespace string) (observation DynamicResourceObservation) { if d == nil { return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "not_observed"} } d.mu.RLock() defer d.mu.RUnlock() if d.stopped { return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "cache_stopped"} } scope := dynamicObservationScope{} if entry := d.informers[informerKey{gvr: gvr}]; entry != nil { scope.clusterWide = true scope.entries = []*informerEntry{entry} } else { for key, entry := range d.informers { if key.gvr == gvr { scope.entries = append(scope.entries, entry) scope.namespaces = append(scope.namespaces, key.ns) } } sort.Strings(scope.namespaces) } return d.observationLocked(gvr, namespace, scope) } func (d *DynamicResourceCache) observationLocked(gvr schema.GroupVersionResource, namespace string, scope dynamicObservationScope) (observation DynamicResourceObservation) { defer func() { r := d.probeRetries[gvr] if r == nil { return } if namespace != "" && !r.whole && !slices.Contains(r.unanswered, namespace) { if observation.ReasonCode == "scope_probe_incomplete" { observation.State, observation.ReasonCode = DynamicObservationUnwatched, "not_observed" observation.Scope = DynamicObservationScopeExplicitNamespaces } return } if namespace != "" { if _, ok := d.informers[informerKey{gvr: gvr, ns: namespace}]; ok && r.whole { return } } observation.UnansweredNamespaces = slices.Clone(r.unanswered) observation.ProbeRetrying = r.running && !r.settled() if r.rejected { // Watches that started before the 401 keep their own state, but // the checks still outstanding were refused, not unanswered. observation.ReasonCode = "credentials_rejected" } if (r.whole || namespace == "" && !r.fullWalk) && !observation.ProbeRetrying && observation.ReasonCode != "credentials_rejected" { observation.ReasonCode = "scope_probe_incomplete" } }() if len(scope.entries) == 0 { if retained, ok := d.observations[gvr]; ok { observation = DynamicResourceObservation{State: retained.state, Truncated: retained.truncated, ReasonCode: retained.reasonCode, ObservedAt: &retained.observedAt} if r := d.probeRetries[gvr]; r != nil && !r.whole && len(r.unanswered) == 0 && len(d.fallbackNamespaces()) > 0 && d.gvrIsNamespaced(gvr) { observation.Scope = DynamicObservationScopeExplicitNamespaces } return observation } if d.config.Discovery != nil && d.config.Discovery.GetKindForGVR(gvr) != "" && !d.config.Discovery.SupportsWatchGVR(gvr) { return DynamicResourceObservation{State: DynamicObservationUnsupported, ReasonCode: "list_watch_unsupported"} } return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "not_observed"} } readiness := scope.entries var serving [1]*informerEntry if namespace != "" { readiness = nil entry := d.informers[informerKey{gvr: gvr}] if entry == nil { entry = d.informers[informerKey{gvr: gvr, ns: namespace}] } if entry != nil { serving[0] = entry readiness = serving[:] } } state := DynamicObservationSynced if len(readiness) == 0 { state = DynamicObservationUnwatched } var oldestUnsynced *informerEntry for _, candidate := range readiness { if !candidate.informer.HasSynced() { if oldestUnsynced == nil || candidate.startedAt.Before(oldestUnsynced.startedAt) { oldestUnsynced = candidate } state = DynamicObservationSyncing } } observation = DynamicResourceObservation{State: state} if scope.clusterWide { observation.Scope = DynamicObservationScopeCluster } else { observation.Scope = DynamicObservationScopeExplicitNamespaces observation.Namespaces = scope.namespaces observation.Truncated = !d.config.NamespaceScoped && (d.config.NamespaceFallbacksTruncated || d.fanoutIncomplete[gvr]) } switch { case state == DynamicObservationUnwatched: observation.ReasonCode = "not_observed" case state == DynamicObservationSyncing && time.Since(oldestUnsynced.startedAt) >= d.syncStallAfter(): observation.ReasonCode = "sync_stalled" case state == DynamicObservationSyncing: observation.ReasonCode = "initial_sync" case observation.Truncated: observation.ReasonCode = "namespace_fanout_truncated" case observation.Scope == DynamicObservationScopeExplicitNamespaces: observation.ReasonCode = "namespace_partial" default: observation.ReasonCode = "informer_synced" } return observation } // factoryForNs returns the informer factory for ns, creating and caching a // namespace-filtered factory on first use. ns == "" is the cluster-wide // factory. Caller must hold d.mu. func (d *DynamicResourceCache) factoryForNs(ns string) dynamicinformer.DynamicSharedInformerFactory { if ns == "" { return d.factory } if f, ok := d.nsFactories[ns]; ok { return f } f := dynamicinformer.NewFilteredDynamicSharedInformerFactory(d.config.DynamicClient, 0, ns, nil) d.nsFactories[ns] = f return f } // --------------------------------------------------------------------------- // EnsureWatching / startWatching / probeAccess // --------------------------------------------------------------------------- // EnsureWatching starts watching a resource type if not already watching. // The sync happens asynchronously — callers should use WaitForSync if they need to wait. func (d *DynamicResourceCache) EnsureWatching(gvr schema.GroupVersionResource) error { return d.ensureWatching(gvr, "") } // ensureWatching guarantees an informer covering preferredNS exists for gvr. // preferredNS == "" means "any/all namespaces": probe cluster-wide and, if // denied, fall back to the configured NamespaceFallback. A non-empty // preferredNS is a specific request: a cluster-wide informer (if the identity // can list cluster-wide) covers it; otherwise we watch that one namespace. // This is what lets a namespace-restricted user read a CRD in the namespaces // they actually have access to, instead of being pinned to a single fallback. func (d *DynamicResourceCache) ensureWatching(gvr schema.GroupVersionResource, preferredNS string) error { return d.EnsureWatchingContext(context.Background(), gvr, preferredNS) } // EnsureWatchingContext starts watching gvr if no watch covers preferredNS yet // ("" for every namespace). ctx bounds only how long this caller waits for // discovery and for Radar's access check; the check and any watches it starts // belong to the cache and outlive ctx. func (d *DynamicResourceCache) EnsureWatchingContext(ctx context.Context, gvr schema.GroupVersionResource, preferredNS string) error { if err := ctx.Err(); err != nil { return err } if d == nil { return fmt.Errorf("dynamic resource cache not initialized") } // Check if resource supports list/watch before attempting to watch if d.config.Discovery != nil && !d.config.Discovery.SupportsWatchGVR(gvr) { return fmt.Errorf("%w: resource %s.%s/%s does not support list/watch", ErrCollectionNotSupported, gvr.Resource, gvr.Group, gvr.Version) } if d.hasCoveringInformer(gvr, preferredNS) { return nil } if err := d.recentDenial(gvr, preferredNS); err != nil { return err } // If CRD discovery is in progress, wait for it to finish if d.GetDiscoveryStatus() == CRDDiscoveryInProgress { select { case <-d.discoveryDone: case <-ctx.Done(): return ctx.Err() case <-time.After(45 * time.Second): log.Printf("[dynamic cache] Timeout waiting for CRD discovery, probing %s independently", gvr.Resource) } if d.hasCoveringInformer(gvr, preferredNS) { return nil } if err := d.recentDenial(gvr, preferredNS); err != nil { return err } } // Probe access (cluster-wide first, then fallback namespaces) BEFORE // acquiring the write lock; the result tells us which scopes to watch. // Singleflight per (gvr, preferredNS): concurrent callers — e.g. a // parallel enrichment fan-out over many objects of one unwatched kind — // share one probe fan-out + informer start instead of stampeding the // apiserver with redundant limit=1 lists. The shared probe runs under the // cache's own budget, never a caller's context: every caller that joined // it would otherwise inherit the deadline and cancellation of whichever // request started it. ctx bounds only this caller's wait. result := d.watchStarts.DoChan(gvr.String()+"|"+preferredNS, func() (any, error) { if d.hasCoveringInformer(gvr, preferredNS) { return nil, nil } if preferredNS == "" { d.mu.RLock() r := d.probeRetries[gvr] retrying := r != nil && r.running d.mu.RUnlock() if retrying { if len(d.readEntries(gvr, "")) > 0 { return nil, nil } if d.Observation(gvr).State == DynamicObservationDenied { return nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("collector cannot list")) } return nil, errScopeProbeIncomplete } } o := d.walkScopes(gvr, preferredNS) scopes, complete, err := o.scopes, o.complete, o.err defer d.noteWalk(gvr, o) if err != nil { if !d.retainAuthProbeFailure(gvr, preferredNS, complete, err) && errors.Is(err, errScopeProbeIncomplete) && preferredNS == "" { d.retainObservation(gvr, DynamicObservationDeferred, "scope_probe_incomplete", true) } return nil, noAccessError(gvr, err) } for _, scopeNS := range scopes { if err := d.startWatching(gvr, scopeNS); err != nil { truncated := preferredNS == "" && len(scopes) > 0 && scopes[0] != "" && (!complete || d.config.NamespaceFallbacksTruncated) d.retainObservation(gvr, DynamicObservationDeferred, "watch_start_failed", truncated) return nil, err } } return nil, nil }) select { case <-ctx.Done(): return ctx.Err() case r := <-result: return r.Err } } // HasCoveringInformer reports whether an informer serves reads of gvr in ns // ("" for all namespaces), synced or not. func (d *DynamicResourceCache) HasCoveringInformer(gvr schema.GroupVersionResource, ns string) bool { if d == nil { return false } return d.hasCoveringInformer(gvr, ns) } // hasCoveringInformer reports whether an existing informer already serves // reads for (gvr, ns), and must agree with readEntries: a cluster-wide // informer covers every namespace; a specific ns is also covered by its own // namespace-scoped informer. ns == "" is covered ONLY by a cluster-wide // informer — not by incidental namespace-scoped ones, since readEntries(gvr, // "") won't read those. Treating them as covering here would let // ensureWatching skip the probe and then have List(gvr, "") find nothing, // returning a spurious "informer not found" instead of probing cluster-wide // (or returning a clean forbidden). func (d *DynamicResourceCache) hasCoveringInformer(gvr schema.GroupVersionResource, ns string) bool { d.mu.RLock() defer d.mu.RUnlock() if _, ok := d.informers[informerKey{gvr: gvr}]; ok { return true } if ns == "" { // Covered without a cluster-wide informer only when a previous // all-namespaces ensureWatching already fanned out across the // fallback namespaces; readEntries(gvr, "") unions those. return d.fallbackResolved[gvr] } _, ok := d.informers[informerKey{gvr: gvr, ns: ns}] return ok } // startWatching creates and starts an informer for (gvr, scopeNS), where // scopeNS == "" is cluster-wide. No access probe — the caller has decided the // scope. Each informer runs under its own context derived from the global // stop channel, so a single informer can be cancelled independently (the // idle reaper relies on this) while Stop() still tears them all down. func (d *DynamicResourceCache) startWatching(gvr schema.GroupVersionResource, scopeNS string) error { return d.startWatchingScope(gvr, scopeNS, false) } // errNamespaceWatchesKept refuses a cluster-wide watch that would replace // namespace watches. var errNamespaceWatchesKept = errors.New("namespace watches kept") // startWatchingScope is startWatching; keepNamespaceWatches refuses a // cluster-wide watch that would replace namespace watches, deciding under // the same lock that would replace them. func (d *DynamicResourceCache) startWatchingScope(gvr schema.GroupVersionResource, scopeNS string, keepNamespaceWatches bool) error { d.mu.Lock() defer d.mu.Unlock() if d.stopped { // A warmup or read probe can race Stop (context switch); informers // created now would never be shut down with the rest of the cache. return fmt.Errorf("dynamic cache stopped") } key := informerKey{gvr: gvr, ns: scopeNS} if _, exists := d.informers[key]; exists { return nil } // Enforce the "never both" invariant: a GVR has either one cluster-wide // informer OR namespace-scoped ones, never both (overlap would duplicate // objects in ListWatched and double-fire change callbacks). if scopeNS != "" { // A cluster-wide informer already covers this namespace — don't add a // redundant namespaced watch. if _, exists := d.informers[informerKey{gvr: gvr}]; exists { return nil } } else { if keepNamespaceWatches { for k := range d.informers { if k.gvr == gvr && k.ns != "" { return errNamespaceWatchesKept } } } // Starting cluster-wide supersedes any namespace-scoped informers for // this GVR; stop and drop them. for k, e := range d.informers { if k.gvr == gvr && k.ns != "" { e.cancel() delete(d.informers, k) d.informerGeneration++ } } } factory := d.factoryForNs(scopeNS) informer := factory.ForResource(gvr).Informer() // Apply the dynamic-cache transform BEFORE informer.Run so every // object entering the store is shrunk in place. SetTransform must // be called pre-Run (returns ErrRunning otherwise). If it ever // fails we log and continue — the informer still functions, just // with fattier cached objects. if err := informer.SetTransform(DropUnstructuredManagedFields); err != nil { log.Printf("Warning: SetTransform failed for %v: %v (cache will retain managedFields/CRD schemas)", gvr, err) } // The factory makes one informer per GVR and Radar never drops a // cluster-wide one, so this informer is new. Code that stops informers // must build a fresh factory: this one would hand back a stopped informer // that reports synced. Without an index, reads of it from this GVR fail // rather than miss. indexers := cache.Indexers{} if !isEventResource(gvr) { maps.Copy(indexers, ownerUIDIndexers) } maps.Copy(indexers, gitopsMembersIndexers[gvr.GroupResource()]) maps.Copy(indexers, kueueQueueIndexers[gvr.GroupResource()]) for name := range informer.GetIndexer().GetIndexers() { delete(indexers, name) } if len(indexers) > 0 { if err := informer.AddIndexers(indexers); err != nil { log.Printf("Warning: indexing %v failed: %v", gvr, err) } } ctx, cancel := context.WithCancel(context.Background()) go func() { select { case <-d.stopCh: cancel() case <-ctx.Done(): } }() d.informerGeneration++ d.informers[key] = &informerEntry{ informer: informer, cancel: cancel, startedAt: time.Now().UTC(), } delete(d.observations, gvr) kind := d.gvrToKind(gvr) d.addDynamicChangeHandlers(informer, kind, gvr) // Re-apply handlers registered via AddGVRChangeHandler so informers created // lazily (or re-created after an idle reap) still feed derived caches. for _, h := range d.gvrHandlers[gvr] { if _, err := informer.AddEventHandler(h); err != nil { log.Printf("Warning: re-applying change handler for %v failed: %v", gvr, err) } } go informer.Run(ctx.Done()) informerCount := len(d.informers) scopeDesc := "cluster-wide" if scopeNS != "" { scopeDesc = "namespace " + scopeNS } log.Printf("Started watching dynamic resource: %s.%s/%s (%s) (total dynamic informers: %d)", gvr.Resource, gvr.Group, gvr.Version, scopeDesc, informerCount) go func() { syncCtx, syncCancel := context.WithTimeout(ctx, 30*time.Second) defer syncCancel() if !cache.WaitForCacheSync(syncCtx.Done(), informer.HasSynced) { select { case <-ctx.Done(): return default: log.Printf("Warning: cache sync timeout for %v (%s)", gvr, scopeDesc) } } else { log.Printf("Dynamic resource synced: %s.%s/%s (%s)", gvr.Resource, gvr.Group, gvr.Version, scopeDesc) } d.mu.Lock() if e := d.informers[key]; e != nil { e.synced = true } d.mu.Unlock() }() return nil } // nsCtxExpired reports whether a probe error is a context deadline/cancel — // the one non-auth error class that must NOT fail open into a grant during // the fanout walk. func nsCtxExpired(err error) bool { return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) } // IsClientRateLimited reports an error from client-go's client-side rate // limiter: the request was never sent because its wait would have run past // the context deadline. client-go wraps the limiter's own error, which is not // a context error, in this fixed message. func IsClientRateLimited(err error) bool { return err != nil && strings.Contains(err.Error(), "client rate limiter Wait returned an error") } // errScopeProbeIncomplete marks a namespace walk that ended before every // candidate answered and granted none: not a denial, so not retained as one. var errScopeProbeIncomplete = errors.New("namespace access probe incomplete") // probeUnanswered reports a probe the API server never answered: it ran out // of time or the client-side rate limiter refused to send it. Either says // nothing about access, so it is neither a grant nor a denial. func probeUnanswered(err error) bool { return nsCtxExpired(err) || IsClientRateLimited(err) } // fallbackNamespaces returns the configured namespace-fallback candidates, // preferring the multi-namespace form. // fanoutSettled reports whether the all-namespaces scope for gvr is fully // resolved. A multi-candidate walk settles via the fallbackResolved marker; // a SINGLE-candidate configuration is trivially settled the moment its one // informer exists — a legacy --namespace user reading through explicit // namespace requests must not have counts withheld waiting for an // all-namespaces read that their UI never issues. Caller must hold d.mu. func (d *DynamicResourceCache) fanoutSettledLocked(gvr schema.GroupVersionResource) bool { if r := d.probeRetries[gvr]; r != nil && !r.settled() { return false } if d.fallbackResolved[gvr] { return true } fallbacks := d.fallbackNamespaces() if len(fallbacks) != 1 { return false } _, ok := d.informers[informerKey{gvr: gvr, ns: fallbacks[0]}] return ok } // FallbackNamespaces returns the namespaces the cache walks when its identity // cannot list a namespaced resource cluster-wide. func (d *DynamicResourceCache) FallbackNamespaces() []string { if d == nil { return nil } return append([]string(nil), d.fallbackNamespaces()...) } func (d *DynamicResourceCache) fallbackNamespaces() []string { if len(d.config.NamespaceFallbacks) > 0 { return d.config.NamespaceFallbacks } if d.config.NamespaceFallback != "" { return []string{d.config.NamespaceFallback} } return nil } // probeScopes decides which scopes to watch for gvr via limit=1 list probes. // It returns the scope namespaces to start informers for — [""] means one // cluster-wide informer — or a forbidden error when the identity can list // the resource neither cluster-wide nor in any candidate namespace. // preferredNS is the namespace the caller actually wants; when cluster-wide // is denied and the caller named none, every configured fallback namespace // is probed and each granted one becomes a scope. func (d *DynamicResourceCache) probeScopes(gvr schema.GroupVersionResource, preferredNS string) (scopes []string, complete bool, err error) { o := d.walkScopes(gvr, preferredNS) return o.scopes, o.complete, o.err } // probeOutcome is one access walk's result. type probeOutcome struct { startedAt time.Time scopes []string complete bool err error // unanswered are namespaces whose check got no answer; whole // means the cluster-wide check itself got none, so no candidate was tried. unanswered []string whole bool fullWalk bool clusterAnswered bool answered []string // failedOpen marks a cluster-wide scope chosen because the cluster-wide // check failed for a reason other than a refusal, not because it passed. failedOpen bool } func (d *DynamicResourceCache) walkScopes(gvr schema.GroupVersionResource, preferredNS string) probeOutcome { return d.walkScopesContext(d.probeCtx, gvr, preferredNS) } func (d *DynamicResourceCache) walkScopesContext(parent context.Context, gvr schema.GroupVersionResource, preferredNS string) (outcome probeOutcome) { select { case d.probeSlots <- struct{}{}: defer func() { <-d.probeSlots }() case <-parent.Done(): return probeOutcome{startedAt: time.Now(), whole: true, err: parent.Err()} } startedAt := time.Now() clusterAnswered := false defer func() { outcome.startedAt = startedAt outcome.clusterAnswered = clusterAnswered outcome.fullWalk = outcome.fullWalk || preferredNS == "" || d.config.NamespaceScoped }() // The budget covers the cluster-wide probe plus the whole candidate walk; // scale it with the candidate count so a 20-namespace fanout isn't judged // by a budget sized for the single-fallback case, while staying bounded // for the synchronous read paths that call ensureWatching. fallbacks := slices.Clone(d.fallbackNamespaces()) if preferredNS == "" { d.mu.RLock() if r := d.probeRetries[gvr]; r != nil { for _, ns := range r.unanswered { if !slices.Contains(fallbacks, ns) { fallbacks = append(fallbacks, ns) } } } d.mu.RUnlock() } budget := scopeProbeBaseBudget if n := len(fallbacks); n > 1 { budget = min(scopeProbeBaseBudget+time.Duration(n)*500*time.Millisecond, 15*time.Second) } ctx, cancel := context.WithTimeout(parent, budget) defer cancel() // Forced namespace mode (--namespace-scope): pin NAMESPACED resources to the // configured namespace. Cluster-scoped resources have no namespace dimension, // so they fall through to the cluster-wide path below — pinning them to a // namespace would list nothing. if d.config.NamespaceScoped && d.config.Namespace != "" && d.gvrIsNamespaced(gvr) { ns, err := d.classifyScope(gvr, d.config.Namespace, d.listProbe(ctx, gvr, d.config.Namespace)) if probeUnanswered(err) { return probeOutcome{unanswered: []string{d.config.Namespace}, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, err)} } if err != nil { return probeOutcome{complete: true, answered: []string{d.config.Namespace}, err: err} } return probeOutcome{scopes: []string{ns}, answered: []string{ns}, complete: true} } // Cluster-wide first — one informer then serves every namespace. err := d.listProbe(ctx, gvr, "") if probeUnanswered(err) { var unanswered []string if preferredNS != "" { unanswered = []string{preferredNS} } return probeOutcome{whole: true, unanswered: unanswered, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, err)} } clusterAnswered = true if err == nil { return probeOutcome{scopes: []string{""}, complete: true, fullWalk: true, answered: []string{""}} } if apierrors.IsUnauthorized(err) { return probeOutcome{complete: true, fullWalk: true, err: err} } if !isAuthProbeError(err) { // Transient/NotFound on proxy-fronted clusters — fail open to a // cluster-wide informer rather than disabling the kind; real // problems surface when the informer lists. log.Printf("[dynamic cache] Cluster-wide probe for %s.%s/%s returned non-auth error (allowing): %v", gvr.Resource, gvr.Group, gvr.Version, err) return probeOutcome{scopes: []string{""}, complete: true, fullWalk: true, answered: []string{""}, failedOpen: true} } if !d.gvrIsNamespaced(gvr) { return probeOutcome{complete: true, fullWalk: true, err: err} // cluster-scoped resource, no namespace to fall back to } if preferredNS != "" { ns, err := d.classifyScope(gvr, preferredNS, d.listProbe(ctx, gvr, preferredNS)) if probeUnanswered(err) { return probeOutcome{unanswered: []string{preferredNS}, err: err} } if err != nil { return probeOutcome{complete: true, answered: []string{preferredNS}, err: err} } return probeOutcome{scopes: []string{ns}, answered: []string{ns}, complete: true} } if len(fallbacks) == 0 { return probeOutcome{complete: true, err: err} } granted := make([]string, 0, len(fallbacks)) answered := make(map[string]bool, len(fallbacks)) complete := true var unanswered error var unauthorized error // mu guards the walk's results while candidates are checked at once. var mu sync.Mutex // probe lists one candidate under its own deadline and records the // answer. It reports whether the walk can go on, and the probe's error. probe := func(ns string, deadline time.Duration) (bool, error) { // Per-candidate sub-deadline: without it a single consistently slow // namespace eats the whole walk budget on every retry, and since // retries restart from the first candidate, later namespaces would // never be reached at all. nsCtx, nsCancel := context.WithTimeout(ctx, deadline) probeErr := d.listProbe(nsCtx, gvr, ns) nsCancel() mu.Lock() defer mu.Unlock() switch { case apierrors.IsUnauthorized(probeErr): unauthorized = probeErr return false, probeErr case ctx.Err() != nil: // The probe ran into the shared deadline, so whatever it returned // is no answer. The cache will retry it within its own budget. complete, unanswered = false, ctx.Err() return false, probeErr case IsClientRateLimited(probeErr): case probeUnanswered(probeErr): complete, unanswered = false, probeErr default: answered[ns] = true if scoped, nsErr := d.classifyScope(gvr, ns, probeErr); nsErr == nil { granted = append(granted, scoped) } } return true, probeErr } // The client-side rate limiter refuses a request whose wait would run past // its deadline, which says nothing about the namespace. Candidates it // refused are retried after the others, each with an equal share of what // is left of the budget, so none can starve the rest. var throttled []string var stopped atomic.Bool check := func(ns string) { ok, probeErr := probe(ns, 2*time.Second) if !ok { stopped.Store(true) } else if IsClientRateLimited(probeErr) { mu.Lock() throttled = append(throttled, ns) mu.Unlock() } } // One at a time, a kind refused everywhere costs a round trip per // candidate, which adds up to minutes on a page reading many such kinds. // Candidates share only the slots no other walk holds, so the checks // running at once stay capped and a busy startup walks as it always did. var candidates sync.WaitGroup for _, ns := range fallbacks { if ctx.Err() != nil { mu.Lock() complete = false mu.Unlock() break } if stopped.Load() { break } select { case d.probeSlots <- struct{}{}: candidates.Go(func() { defer func() { <-d.probeSlots }() check(ns) }) default: check(ns) } } candidates.Wait() byCandidateOrder := func(a, b string) int { return slices.Index(fallbacks, a) - slices.Index(fallbacks, b) } slices.SortFunc(granted, byCandidateOrder) slices.SortFunc(throttled, byCandidateOrder) deadline, _ := ctx.Deadline() for i, ns := range throttled { if ctx.Err() != nil { complete = false break } ok, probeErr := probe(ns, time.Until(deadline)/time.Duration(len(throttled)-i)) if !ok { break } if IsClientRateLimited(probeErr) { complete, unanswered = false, probeErr } } if unauthorized != nil { return probeOutcome{complete: true, fullWalk: true, err: unauthorized} } var silent, answeredNamespaces []string for _, ns := range fallbacks { if !answered[ns] { silent = append(silent, ns) } else { answeredNamespaces = append(answeredNamespaces, ns) } } if len(granted) == 0 { if !complete { // Not every candidate answered, so this is not a denial. return probeOutcome{unanswered: silent, answered: answeredNamespaces, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, cmp.Or(unanswered, ctx.Err(), err))} } return probeOutcome{complete: true, answered: answeredNamespaces, err: err} // original cluster-wide forbidden — no candidate granted } return probeOutcome{scopes: granted, complete: complete, unanswered: silent, answered: answeredNamespaces} } type probeRetry struct { unanswered []string whole bool fullWalk bool // running: the fast retries within the budget are under way. running bool // owned: a goroutine is re-checking this record, fast or slowly. owned bool // rejected: Kubernetes rejected Radar's credentials during a re-check, // so nothing re-checks the record again. rejected bool answered map[string]time.Time deadline time.Time // ctx is the owner's lifetime: cancel ends it when the record settles, // is replaced, or the cache stops. ctx context.Context cancel context.CancelFunc // done is closed when the fast retries end. done chan struct{} } func (r *probeRetry) settled() bool { return r.fullWalk && !r.whole && len(r.unanswered) == 0 } const probeRetryBudget = 30 * time.Second var defaultProbeRetryBackoff = []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 4 * time.Second} func (d *DynamicResourceCache) noteWalk(gvr schema.GroupVersionResource, o probeOutcome) { d.mu.Lock() defer d.mu.Unlock() if d.stopped { return } r := d.probeRetries[gvr] if r == nil && len(o.unanswered) == 0 && !o.whole { if o.fullWalk && len(o.scopes) > 0 { d.fallbackResolved[gvr] = true d.fanoutIncomplete[gvr] = false } return } // A record whose owner ended, is ending, or met rejected credentials needs // a fresh owner for a new gap, and the fresh owner takes over every gap // still outstanding; a live owner picks the gap up itself. Owners decide // to end under d.mu, so no gap lands between that decision and the // record's release. if r == nil || ((!r.owned || r.rejected || r.ctx == nil || r.ctx.Err() != nil) && (len(o.unanswered) > 0 || o.whole)) { prev := r r = &probeRetry{running: true, owned: true, fullWalk: d.fallbackResolved[gvr], answered: map[string]time.Time{}, deadline: o.startedAt.Add(probeRetryBudget), done: make(chan struct{})} if prev != nil { if prev.cancel != nil { prev.cancel() } r.fullWalk, r.whole = prev.fullWalk, prev.whole r.unanswered = slices.Clone(prev.unanswered) maps.Copy(r.answered, prev.answered) } r.ctx, r.cancel = context.WithCancel(d.probeCtx) d.probeRetries[gvr] = r go d.retryUnanswered(gvr, r) } for _, ns := range o.answered { r.answered[ns] = time.Now() r.unanswered = slices.DeleteFunc(r.unanswered, func(n string) bool { return n == ns }) } for _, ns := range o.unanswered { // An answer that arrived during this walk supersedes its stale gap. if !r.answered[ns].After(o.startedAt) && !slices.Contains(r.unanswered, ns) { r.unanswered = append(r.unanswered, ns) } } if o.clusterAnswered { r.answered[""] = time.Now() r.whole = false } if o.fullWalk && !o.whole { r.fullWalk, r.whole = true, false } else if o.whole && !r.answered[""].After(o.startedAt) { r.whole = true } for key := range d.informers { if key.gvr == gvr { if key.ns == "" { r.unanswered, r.whole, r.fullWalk = nil, false, true } else { r.unanswered = slices.DeleteFunc(r.unanswered, func(ns string) bool { return ns == key.ns }) } } } if r.settled() { r.cancel() d.settleProbeLocked(gvr) } else { d.fallbackResolved[gvr] = false d.fanoutIncomplete[gvr] = true } } func (d *DynamicResourceCache) noteAnswered(gvr schema.GroupVersionResource, ns string) { d.mu.Lock() defer d.mu.Unlock() if r := d.probeRetries[gvr]; r != nil { r.answered[ns] = time.Now() if ns == "" { r.unanswered, r.whole, r.fullWalk = nil, false, true } else { r.unanswered = slices.DeleteFunc(r.unanswered, func(n string) bool { return n == ns }) } if r.settled() { r.cancel() d.settleProbeLocked(gvr) } } } // retryUnanswered owns r: it re-checks r's unanswered access checks with // backoff within the budget, then, if they still haven't answered, slowly // until they do, credentials are rejected, or the cache stops. func (d *DynamicResourceCache) retryUnanswered(gvr schema.GroupVersionResource, r *probeRetry) { fast, cancel := context.WithDeadline(r.ctx, r.deadline) backoff := d.config.ProbeRetryBackoff if len(backoff) == 0 { backoff = defaultProbeRetryBackoff } for _, delay := range backoff { if !waitFor(fast, delay) || d.retryOnce(fast, gvr, r) { break } } cancel() d.mu.Lock() r.running = false settled := r.settled() if settled { d.settleProbeLocked(gvr) r.owned = false r.cancel() } close(r.done) d.mu.Unlock() if !settled { d.recoverProbes(gvr, r) } } // defaultProbeRecoveryIntervals space the re-checks of access checks still // unanswered when their retry budget ran out; the last repeats. var defaultProbeRecoveryIntervals = []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute} // recoverProbes keeps re-checking, slowly, access checks that were still // unanswered when their retry budget ran out. Readers of the cache alone, // such as GitOps detection, never probe a kind themselves, so without this a // kind whose checks timed out or were rate-limited at startup would stay // unwatched until something read it by name. Each attempt checks as a first // walk would, so an identity limited to some namespaces still falls back to // them, where watching cluster-wide on a guess would never sync. func (d *DynamicResourceCache) recoverProbes(gvr schema.GroupVersionResource, r *probeRetry) { intervals := d.config.ProbeRecoveryIntervals if len(intervals) == 0 { intervals = defaultProbeRecoveryIntervals } release := func() bool { d.mu.Lock() defer d.mu.Unlock() if r.rejected || r.settled() || r.ctx.Err() != nil { r.owned = false r.cancel() return true } return false } for attempt := 0; !release(); attempt++ { if !waitFor(r.ctx, intervals[min(attempt, len(intervals)-1)]) { continue } ctx, cancel := context.WithTimeout(r.ctx, probeRetryBudget) d.retryOnce(ctx, gvr, r) cancel() } } func waitFor(ctx context.Context, delay time.Duration) bool { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } // retryOnce re-checks r's unanswered access checks once: the whole walk when // the cluster-wide check never answered, otherwise each unanswered namespace. // It reports whether retrying should stop: the checks settled, ctx ended, a // watch couldn't start, or Kubernetes rejected Radar's credentials. func (d *DynamicResourceCache) retryOnce(ctx context.Context, gvr schema.GroupVersionResource, r *probeRetry) bool { d.mu.RLock() pending, whole, fullWalk := slices.Clone(r.unanswered), r.whole, r.fullWalk settled := r.settled() d.mu.RUnlock() if settled { return true } if whole || !fullWalk { o := d.walkScopesContext(ctx, gvr, "") if ctx.Err() != nil { return true } for _, scope := range o.scopes { // A cluster-wide check that failed, rather than passed, is no // reason to replace namespace watches that work: try again. if err := d.startWatchingScope(gvr, scope, o.failedOpen); errors.Is(err, errNamespaceWatchesKept) { return false } else if err != nil { return true } } if o.complete { d.retainAuthProbeFailure(gvr, "", true, o.err) } d.noteWalk(gvr, o) if apierrors.IsUnauthorized(o.err) { d.mu.Lock() r.rejected = true d.mu.Unlock() return true } } else { deadline, _ := ctx.Deadline() for i, ns := range pending { if ctx.Err() != nil { return true } d.mu.RLock() stillPending := slices.Contains(r.unanswered, ns) d.mu.RUnlock() if !stillPending { continue } select { case d.probeSlots <- struct{}{}: case <-ctx.Done(): return true } probeCtx, cancel := context.WithTimeout(ctx, min(3*time.Second, time.Until(deadline)/time.Duration(len(pending)-i))) probeErr := d.listProbe(probeCtx, gvr, ns) unanswered := probeUnanswered(probeErr) || probeCtx.Err() != nil cancel() <-d.probeSlots if apierrors.IsUnauthorized(probeErr) { // A 401 rejects Radar's credentials, not this namespace: // stop for good, as the first walk does. d.retainAuthProbeFailure(gvr, "", true, probeErr) d.mu.Lock() r.rejected = true d.mu.Unlock() return true } if unanswered { continue } if scoped, err := d.classifyScope(gvr, ns, probeErr); err == nil { if err := d.startWatching(gvr, scoped); err != nil { return true } } d.noteAnswered(gvr, ns) } } d.mu.RLock() done := r.settled() d.mu.RUnlock() return done } func (d *DynamicResourceCache) settleProbeLocked(gvr schema.GroupVersionResource) { if r := d.probeRetries[gvr]; r != nil && !r.settled() { return } d.fanoutIncomplete[gvr] = false if retained, ok := d.observations[gvr]; ok && retained.reasonCode == "scope_probe_incomplete" { delete(d.observations, gvr) } for key := range d.informers { if key.gvr == gvr { d.fallbackResolved[gvr] = true return } } if retained, ok := d.observations[gvr]; ok && retained.reasonCode == "credentials_rejected" { return } truncated := d.namespaceProbeTruncated(gvr, "", true) reasonCode := "access_denied" if truncated { reasonCode = "access_denied_partial_probe" } d.observations[gvr] = retainedObservation{observedAt: time.Now().UTC(), state: DynamicObservationDenied, reasonCode: reasonCode, truncated: truncated} } func (d *DynamicResourceCache) namespaceProbeTruncated(gvr schema.GroupVersionResource, preferredNS string, complete bool) bool { if preferredNS != "" || d.config.NamespaceScoped { return false } return !complete || (d.config.NamespaceFallbacksTruncated && d.gvrIsNamespaced(gvr)) } func (d *DynamicResourceCache) listProbe(ctx context.Context, gvr schema.GroupVersionResource, namespace string) error { if namespace != "" { _, err := d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{Limit: 1}) return err } _, err := d.config.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{Limit: 1}) return err } // classifyScope maps a namespace probe result to (scope, err): success → watch // ns; auth error, or a probe that was never sent or ran out of time → no // scope; any other error → fail open and watch ns anyway, matching the // cluster-wide fail-open path. func (d *DynamicResourceCache) classifyScope(gvr schema.GroupVersionResource, ns string, err error) (string, error) { if err == nil { return ns, nil } if isAuthProbeError(err) || probeUnanswered(err) { return "", err } log.Printf("[dynamic cache] Probe for %s.%s/%s in namespace %q returned non-auth error (allowing): %v", gvr.Resource, gvr.Group, gvr.Version, ns, err) return ns, nil } // isAuthProbeError classifies an error as an auth (403/401) failure as // opposed to a transient or NotFound error. Uses the typed K8s helpers only — // substring matching on "forbidden"/"unauthorized" misclassifies admission- // webhook denials and optimistic-concurrency conflicts ("Operation cannot be // fulfilled ... forbidden") on proxy-fronted clusters, permanently disabling // CRDs for the session. func isAuthProbeError(err error) bool { if err == nil { return false } return apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) } // gvrIsNamespaced reads the scope of the exact version asked for: discovery // lists every served version, and an aggregated API can serve one resource // with a different scope per version, so another version's scope can't stand // in. Stale discovery is refreshed first, since an API replaced under the same // name can change scope. A version discovery doesn't list reads as namespaced, // which keeps namespace filters and the --namespace-scope pin in place. func (d *DynamicResourceCache) gvrIsNamespaced(gvr schema.GroupVersionResource) bool { if d.config.Discovery == nil { return true } if err := d.config.Discovery.RefreshIfStale(); err != nil { log.Printf("Warning: failed to refresh API resources: %v", err) } if namespaced, known := d.config.Discovery.NamespacedGVR(gvr); known { return namespaced } return true } // ProbeCount does a quick list with limit=1 and returns the approximate resource count. // Returns -1 if access is denied, -2 if the probe failed for non-auth reasons (caller // should defer), or the count (items + remainingItemCount) on success. // // Exported so callers outside this package (e.g. internal/k8s when deciding // whether to eager-warm high-cardinality CRDs like PolicyReports) can gate // informer creation on cluster size before paying the watch-layer cost. func (d *DynamicResourceCache) ProbeCount(gvr schema.GroupVersionResource) int { count, _ := d.probeCount(gvr) return count } // probeCount is ProbeCount that also returns the error behind -1 and -2. func (d *DynamicResourceCache) probeCount(gvr schema.GroupVersionResource) (int, error) { select { case d.probeSlots <- struct{}{}: defer func() { <-d.probeSlots }() case <-d.probeCtx.Done(): return -2, d.probeCtx.Err() } ctx, cancel := context.WithTimeout(d.probeCtx, 5*time.Second) defer cancel() var list *unstructured.UnstructuredList var err error list, err = d.probeCountList(ctx, gvr) if err != nil { if ctx.Err() != nil { return -2, err } if isAuthProbeError(err) { return -1, err } log.Printf("[dynamic cache] probeCount for %s.%s/%s returned non-auth error (deferring): %v", gvr.Resource, gvr.Group, gvr.Version, err) return -2, err } count := len(list.Items) if remaining := list.GetRemainingItemCount(); remaining != nil { count += int(*remaining) return count, nil } if list.GetContinue() != "" { return -2, nil } return count, nil } func (d *DynamicResourceCache) probeCountList(ctx context.Context, gvr schema.GroupVersionResource) (*unstructured.UnstructuredList, error) { // Match probeScope: under --namespace-scope only NAMESPACED resources are // pinned to the configured namespace. Counting a cluster-scoped resource // inside a namespace lists nothing (or errors), which would misgate the // size-based eager-warm decision, so it stays cluster-wide. if d.config.NamespaceScoped && d.config.Namespace != "" && d.gvrIsNamespaced(gvr) { return d.config.DynamicClient.Resource(gvr).Namespace(d.config.Namespace).List(ctx, metav1.ListOptions{Limit: 1}) } list, err := d.config.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{Limit: 1}) if err == nil { return list, nil } if apierrors.IsUnauthorized(err) { return nil, err } if isAuthProbeError(err) && d.gvrIsNamespaced(gvr) { // Size heuristic only — the first granted fallback namespace is a // good-enough sample for the eager-warm decision. var fallbackErr error for _, ns := range d.fallbackNamespaces() { nsList, nsErr := d.config.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1}) if nsErr == nil { return nsList, nil } if apierrors.IsUnauthorized(nsErr) { return nil, nsErr } if !isAuthProbeError(nsErr) && fallbackErr == nil { fallbackErr = nsErr } } if fallbackErr != nil { return nil, fallbackErr } } return list, err } // gvrToKind converts a GVR to a Kind name using resource discovery. func (d *DynamicResourceCache) gvrToKind(gvr schema.GroupVersionResource) string { if d.config.Discovery != nil { if kind := d.config.Discovery.GetKindForGVR(gvr); kind != "" { return kind } } // Fallback: capitalize and singularize name := gvr.Resource if len(name) > 1 && name[len(name)-1] == 's' { name = name[:len(name)-1] } if len(name) > 0 { return strings.ToUpper(name[:1]) + name[1:] } return name } // --------------------------------------------------------------------------- // Change handlers // --------------------------------------------------------------------------- // safeCallback invokes fn with panic recovery to protect informer goroutines. func (d *DynamicResourceCache) safeCallback(name string, fn func()) { defer func() { if r := recover(); r != nil { log.Printf("ERROR: k8score dynamic cache %s callback panicked: %v", name, r) } }() fn() } func (d *DynamicResourceCache) addDynamicChangeHandlers(inf cache.SharedIndexInformer, kind string, gvr schema.GroupVersionResource) { _, _ = inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj any) { d.enqueueDynamicChange(kind, gvr, obj, nil, OpAdd) }, UpdateFunc: func(oldObj, newObj any) { d.enqueueDynamicChange(kind, gvr, newObj, oldObj, OpUpdate) }, DeleteFunc: func(obj any) { d.enqueueDynamicChange(kind, gvr, obj, nil, OpDelete) }, }) } func (d *DynamicResourceCache) enqueueDynamicChange(kind string, gvr schema.GroupVersionResource, obj any, oldObj any, op string) { u, ok := obj.(*unstructured.Unstructured) if !ok { if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { u, ok = tombstone.Obj.(*unstructured.Unstructured) if !ok { return } } else { return } } // Callbacks consume the resource object, not client-go's delivery // wrapper. Keeping the wrapper here silently drops dynamic deletes in // Radar's timeline callback even though identity was resolved above. obj = u namespace := u.GetNamespace() name := u.GetName() uid := string(u.GetUID()) if d.config.OnReceived != nil { d.safeCallback("OnReceived", func() { d.config.OnReceived(kind) }) } // During initial sync, still fire OnChange (for historical recording) // but skip the channel send (no SSE flood). isSyncAdd := false if op == OpAdd { d.mu.RLock() synced := d.gvrSyncedLocked(gvr, namespace) d.mu.RUnlock() if !synced { isSyncAdd = true if d.config.DebugEvents { log.Printf("[DEBUG] Dynamic initial sync add event: %s/%s/%s (recording historical only)", kind, namespace, name) } } } var diff *DiffInfo if op == OpUpdate && oldObj != nil && obj != nil && d.config.ComputeDiff != nil { d.safeCallback("ComputeDiff", func() { diff = d.config.ComputeDiff(kind, oldObj, obj) }) } change := ResourceChange{ Kind: kind, Namespace: namespace, Name: name, UID: uid, Operation: op, Diff: diff, Group: gvr.Group, Resource: gvr.Resource, } // Always fire OnChange (even during sync adds — Radar uses this for timeline) if d.config.OnChange != nil { d.safeCallback("OnChange", func() { d.config.OnChange(change, obj, oldObj) }) } // Skip channel send during initial sync if isSyncAdd { return } // Send to change channel if d.config.Changes != nil { select { case d.config.Changes <- change: default: if d.config.OnDrop != nil { d.safeCallback("OnDrop", func() { d.config.OnDrop(kind, namespace, name, "channel_full", op) }) } if d.config.DebugEvents { log.Printf("[DEBUG] Dynamic change channel full, dropped: %s/%s/%s op=%s", kind, namespace, name, op) } } } if d.config.OnRecorded != nil { d.safeCallback("OnRecorded", func() { d.config.OnRecorded(kind) }) } } // --------------------------------------------------------------------------- // Read methods // --------------------------------------------------------------------------- // WatchedGVRs returns the GVRs that already have a started informer. Callers // that want to enumerate "what dynamic resources is the cache currently // observing?" should use this — never iterate every CRD from API discovery // and call List() on each, which spins up a new persistent informer per GVR // and grows unbounded on clusters with many CRDs (Upbound AWS alone ships // ~1000 kinds). // // Result is a snapshot; callers must not assume the set is stable across // calls. Synced and unsynced informers are both included — callers that // need only ready data should additionally call WaitForSync. func (d *DynamicResourceCache) WatchedGVRs() []schema.GroupVersionResource { if d == nil { return nil } d.mu.RLock() defer d.mu.RUnlock() return d.distinctGVRsLocked() } // distinctGVRsLocked returns the unique GVRs that have at least one informer, // collapsing the per-namespace keys. Caller must hold d.mu. func (d *DynamicResourceCache) distinctGVRsLocked() []schema.GroupVersionResource { seen := make(map[schema.GroupVersionResource]struct{}, len(d.informers)) out := make([]schema.GroupVersionResource, 0, len(d.informers)) for k := range d.informers { if _, ok := seen[k.gvr]; ok { continue } seen[k.gvr] = struct{}{} out = append(out, k.gvr) } return out } // readEntries returns the informer(s) that serve reads for (gvr, ns). A // cluster-wide informer alone covers every namespace; otherwise reads come // from namespace-scoped informers — the one matching a specific ns, or all of // the matching namespace-scoped informer for a specific ns. e.informer is // immutable after creation, so the returned entries are safe to read outside // the lock (e.synced is not touched here). Returns nil when nothing covers // (gvr, ns). // // ns == "" means cluster-wide and is served ONLY by a cluster-wide informer — // it deliberately does NOT union whatever per-namespace informers happen to // exist, which would make results depend on incidental cache state (and, in a // shared cache, on namespaces another request warmed). Callers wanting a union // over a known namespace set must name it via ListNamespaces. func (d *DynamicResourceCache) readEntries(gvr schema.GroupVersionResource, ns string) []*informerEntry { d.mu.RLock() defer d.mu.RUnlock() if e, ok := d.informers[informerKey{gvr: gvr}]; ok { return []*informerEntry{e} } if ns != "" { if e, ok := d.informers[informerKey{gvr: gvr, ns: ns}]; ok { return []*informerEntry{e} } return nil } // ns == "" ("all namespaces") with no cluster-wide informer. When Radar // connects with a namespace-restricted identity (fallbacks configured), // cluster-wide list was denied so the only informers for this GVR are // per-namespace — union them, otherwise an all-namespaces read returns // nothing even though the per-namespace informers are synced. // // Deliberately NOT gated on fallbackResolved: list surfaces are // best-effort and self-healing (every read re-runs ensureWatching until // the candidate walk settles), matching the typed probe's keep-partial // philosophy. Cardinality/absence assertions are the strict surfaces — // Count(gvr, nil), CountWatched, and IsClusterWideSynced all refuse an // unsettled fanout, because a partial number masquerades as a smaller // truth while a partial list is just an incomplete view. A cluster-wide // cache (no fallbacks configured) stays empty here, preserving the // deterministic cluster-wide-only contract that keeps incidental // per-namespace informers from leaking across users. if len(d.fallbackNamespaces()) == 0 { return nil } var out []*informerEntry for k, e := range d.informers { if k.gvr == gvr { out = append(out, e) } } return out } // indexerItems gathers raw store objects from the given entries, filtered to // namespace when non-empty. Namespace-scoped informers hold disjoint // namespaces, so unioning across entries cannot duplicate an object. func indexerItems(entries []*informerEntry, namespace string) ([]any, error) { var items []any for _, e := range entries { idx := e.informer.GetIndexer() if namespace != "" { got, err := idx.ByIndex(cache.NamespaceIndex, namespace) if err != nil { return nil, err } items = append(items, got...) } else { items = append(items, idx.List()...) } } return items, nil } // gvrSyncedLocked reports whether the informer holding objects of gvr in // namespace has finished its initial sync (cluster-wide informer first, else // the namespace-scoped one). Caller must hold d.mu. func (d *DynamicResourceCache) gvrSyncedLocked(gvr schema.GroupVersionResource, namespace string) bool { if e, ok := d.informers[informerKey{gvr: gvr}]; ok { return e.synced } if e, ok := d.informers[informerKey{gvr: gvr, ns: namespace}]; ok { return e.synced } return false } // getByKeyFromEntries returns the first store object matching key across the // entries. Namespace-scoped informers hold disjoint namespaces, so at most one // can hold a given namespaced key. func getByKeyFromEntries(entries []*informerEntry, key string) (any, bool, error) { for _, e := range entries { item, ok, err := e.informer.GetIndexer().GetByKey(key) if err != nil { return nil, false, err } if ok { return item, true, nil } } return nil, false, nil } // entriesSynced reports whether every entry's informer has completed its // initial sync (informer.HasSynced is the thread-safe source of truth). func entriesSynced(entries []*informerEntry) bool { for _, e := range entries { if !e.informer.HasSynced() { return false } } return len(entries) > 0 } // servedSynced is entriesSynced for an answer the cache vouches for. Stop // keeps the informers, and they go on reporting synced while their stores no // longer change, so a stopped cache vouches for nothing. stopped never resets, // so checking it after the informers keeps a true answer from describing a // cache that had already stopped. func (d *DynamicResourceCache) servedSynced(entries []*informerEntry) bool { if !entriesSynced(entries) { return false } d.mu.RLock() defer d.mu.RUnlock() return !d.stopped } // Count returns the number of resources for a given GVR, optionally filtered by namespaces. // Unlike List(), this avoids allocating a result slice and skips StripUnstructuredFields. // Count reads only what is already watched and synced; it does not start informers. // // A cluster-wide informer serves any namespace filter. Without one, Count can // answer for explicitly-named namespaces when each has its own synced informer. // In namespace-fallback mode, Count(nil) unions the namespace-scoped informers // so it agrees with all-namespace reads. func (d *DynamicResourceCache) Count(gvr schema.GroupVersionResource, namespaces []string) (int, error) { if d == nil { return 0, fmt.Errorf("dynamic resource cache not initialized") } d.mu.RLock() defer d.mu.RUnlock() if d.stopped { return 0, fmt.Errorf("dynamic cache stopped") } if e, ok := d.informers[informerKey{gvr: gvr}]; ok { if !e.informer.HasSynced() { return 0, fmt.Errorf("informer not found or not synced for %v", gvr) } if len(namespaces) == 0 { return len(e.informer.GetIndexer().List()), nil } return countByNamespaces(e, namespaces) } if len(namespaces) == 0 { // No cluster-wide informer. In namespace-fallback mode union the // per-namespace informers so Count agrees with // readEntries on an all-namespaces read; a cluster-wide cache keeps the // strict cluster-wide-only contract and errors. if len(d.fallbackNamespaces()) == 0 { return 0, fmt.Errorf("informer not found or not synced for %v", gvr) } // A truncated candidate walk leaves a partial namespace set whose sum // would silently under-count; only a settled fanout is authoritative. if !d.fanoutSettledLocked(gvr) { return 0, fmt.Errorf("informer not found or not synced for %v", gvr) } total := 0 found := false for k, e := range d.informers { if k.gvr != gvr { continue } if !e.informer.HasSynced() { return 0, fmt.Errorf("informer not found or not synced for %v", gvr) } found = true total += len(e.informer.GetIndexer().List()) } if !found { return 0, fmt.Errorf("informer not found or not synced for %v", gvr) } return total, nil } total := 0 for _, ns := range namespaces { e, ok := d.informers[informerKey{gvr: gvr, ns: ns}] if !ok || !e.informer.HasSynced() { return 0, fmt.Errorf("informer not found or not synced for %v in namespace %s", gvr, ns) } n, err := countByNamespaces(e, []string{ns}) if err != nil { return 0, err } total += n } return total, nil } // CountWatched returns cached object counts for every currently watched and // synced GVR. It never starts informers and never probes the apiserver. func (d *DynamicResourceCache) CountWatched(namespaces []string) map[schema.GroupVersionResource]int { if d == nil { return nil } d.mu.RLock() defer d.mu.RUnlock() if d.stopped { return nil } type watchedSet struct { clusterWide *informerEntry byNamespace map[string]*informerEntry } byGVR := make(map[schema.GroupVersionResource]*watchedSet) for k, e := range d.informers { if !e.informer.HasSynced() { continue } set := byGVR[k.gvr] if set == nil { set = &watchedSet{byNamespace: make(map[string]*informerEntry)} byGVR[k.gvr] = set } if k.ns == "" { set.clusterWide = e } else { set.byNamespace[k.ns] = e } } // Unsynced entries were dropped above, so re-scan for GVRs whose fanout // still has an unsynced sibling — summing the synced subset would report // a silently smaller count instead of "not ready yet". partiallySynced := make(map[schema.GroupVersionResource]bool) for k, e := range d.informers { if !e.informer.HasSynced() { partiallySynced[k.gvr] = true } } counts := make(map[schema.GroupVersionResource]int) for gvr, set := range byGVR { if len(namespaces) == 0 { if set.clusterWide != nil { counts[gvr] = len(set.clusterWide.informer.GetIndexer().List()) continue } if len(d.fallbackNamespaces()) == 0 { continue } // All-namespaces sums over a fanout are only authoritative once // the candidate walk settled and every informer synced. if !d.fanoutSettledLocked(gvr) || partiallySynced[gvr] { continue } total := 0 for _, e := range set.byNamespace { total += len(e.informer.GetIndexer().List()) } counts[gvr] = total continue } if set.clusterWide != nil { n, err := countByNamespaces(set.clusterWide, namespaces) if err == nil { counts[gvr] = n } continue } total := 0 complete := true for _, ns := range namespaces { e, ok := set.byNamespace[ns] if !ok { complete = false break } n, err := countByNamespaces(e, []string{ns}) if err != nil { complete = false break } total += n } if complete { counts[gvr] = total } } return counts } func countByNamespaces(e *informerEntry, namespaces []string) (int, error) { total := 0 for _, ns := range namespaces { items, err := e.informer.GetIndexer().ByIndex(cache.NamespaceIndex, ns) if err != nil { return 0, fmt.Errorf("failed to count resources in namespace %s: %w", ns, err) } total += len(items) } return total, nil } func (d *DynamicResourceCache) CountDirectProbe(ctx context.Context, gvr schema.GroupVersionResource, namespaces []string, maxNamespaces, concurrency int) (int, error) { if d == nil { return 0, fmt.Errorf("dynamic resource cache not initialized") } if d.config.DynamicClient == nil { return 0, fmt.Errorf("dynamic client not initialized") } if len(namespaces) == 0 { return d.countDirectProbeOne(ctx, gvr, "") } if maxNamespaces > 0 && len(namespaces) > maxNamespaces { return 0, ErrResourceCountUnavailable } if concurrency < 1 { concurrency = 1 } if concurrency > len(namespaces) { concurrency = len(namespaces) } type result struct { count int err error } jobs := make(chan string) results := make(chan result, len(namespaces)) var wg sync.WaitGroup for i := 0; i < concurrency; i++ { wg.Add(1) go func() { defer wg.Done() for ns := range jobs { n, err := d.countDirectProbeOne(ctx, gvr, ns) results <- result{count: n, err: err} } }() } go func() { defer close(jobs) for _, ns := range namespaces { select { case <-ctx.Done(): return case jobs <- ns: } } }() wg.Wait() close(results) total := 0 for r := range results { if r.err != nil { return 0, r.err } total += r.count } if err := ctx.Err(); err != nil { return 0, err } return total, nil } func (d *DynamicResourceCache) countDirectProbeOne(ctx context.Context, gvr schema.GroupVersionResource, namespace string) (int, error) { var list *unstructured.UnstructuredList var err error opts := metav1.ListOptions{Limit: directCountProbeLimit} if namespace != "" { list, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, opts) } else { list, err = d.config.DynamicClient.Resource(gvr).List(ctx, opts) } if err != nil { if isAuthProbeError(err) { return 0, ErrResourceCountUnavailable } return 0, fmt.Errorf("failed to probe resource count: %w", err) } if list == nil { return 0, ErrResourceCountUnavailable } if remaining := list.GetRemainingItemCount(); remaining != nil { return len(list.Items) + int(*remaining), nil } if list.GetContinue() == "" { return len(list.Items), nil } return 0, ErrResourceCountUnavailable } // List returns all resources of a given GVR, optionally filtered by namespace. // This is non-blocking — returns whatever data is available immediately. func (d *DynamicResourceCache) List(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { return d.ListContext(context.Background(), gvr, namespace) } // ListContext is List with ctx bounding how long the caller waits for an // on-demand watch to start. func (d *DynamicResourceCache) ListContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil { return nil, err } entries := d.readEntries(gvr, namespace) if len(entries) == 0 { return nil, fmt.Errorf("informer not found for %v", gvr) } items, err := indexerItems(entries, namespace) if err != nil { return nil, fmt.Errorf("failed to list resources: %w", err) } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { if u, ok := item.(*unstructured.Unstructured); ok { result = append(result, StripUnstructuredFields(u)) } } return result, nil } // ListReadOnly is List without its per-object copy: it returns the cached // objects themselves. Informer transforms run before objects enter the store, // and updates replace stored objects rather than mutating them in place. // Callers must not mutate the returned objects or retain them beyond the // synchronous computation that requested them. func (d *DynamicResourceCache) ListReadOnly(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if err := d.ensureWatching(gvr, namespace); err != nil { return nil, err } return d.readOnlyItems(gvr, namespace) } // ListReadOnlyContext is ListReadOnly with ctx bounding the wait to start // watching gvr, as ListContext is to List. func (d *DynamicResourceCache) ListReadOnlyContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil { return nil, err } return d.readOnlyItems(gvr, namespace) } func (d *DynamicResourceCache) readOnlyItems(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { entries := d.readEntries(gvr, namespace) if len(entries) == 0 { return nil, fmt.Errorf("informer not found for %v", gvr) } items, err := indexerItems(entries, namespace) if err != nil { return nil, fmt.Errorf("failed to list resources: %w", err) } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { if u, ok := item.(*unstructured.Unstructured); ok { result = append(result, u) } } return result, nil } // ListWatched returns every cached object for gvr across all currently-watched // scopes (cluster-wide and/or per-namespace), unioned. It does NOT start or // re-probe informers — it reads whatever is already watched. This is for // internal "scan what's already cached" callers (Crossplane audit, Kyverno // PolicyReport indexing) that iterate WatchedGVRs(): unlike List(gvr, ""), // which is cluster-wide-only, it surfaces namespace-scoped contents so those // scanners don't silently drop them in a namespace-restricted install. // Request-facing reads must stay on List / ListNamespaces with explicit // namespaces. func (d *DynamicResourceCache) ListWatched(gvr schema.GroupVersionResource) ([]*unstructured.Unstructured, error) { items, err := d.ListWatchedReadOnly(gvr) if err != nil { return nil, err } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { result = append(result, StripUnstructuredFields(item)) } return result, nil } // ListWatchedReadOnly returns the cached objects covered by ListWatched // without copying them. Informer transforms run before objects enter the store, // and updates replace stored objects rather than mutating them in place. Callers // must not mutate the returned objects or retain them beyond the synchronous // computation that requested them. func (d *DynamicResourceCache) ListWatchedReadOnly(gvr schema.GroupVersionResource) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } entries := d.entriesForGVR(gvr) items, err := indexerItems(entries, "") if err != nil { return nil, fmt.Errorf("failed to list resources: %w", err) } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { if u, ok := item.(*unstructured.Unstructured); ok { result = append(result, u) } } return result, nil } // ListWatchedNamespaceReadOnly reads an already-watched namespace when it fits // within the candidate budget. Larger namespaces return no rows and truncated // coverage rather than an unstable sample. The client-go namespace index allocates its namespace-sized // result first; this avoids materializing other namespaces and bounds subsequent // object inspection, not the index lookup allocation. Returned objects must not // be mutated or retained beyond the synchronous read. func (d *DynamicResourceCache) ListWatchedNamespaceReadOnly(gvr schema.GroupVersionResource, namespace string, limit int) ([]*unstructured.Unstructured, bool, error) { if d == nil { return nil, false, fmt.Errorf("dynamic resource cache not initialized") } if namespace == "" || limit <= 0 { return nil, false, fmt.Errorf("namespace and positive candidate limit are required") } entries, readable := d.servedEntries(gvr, namespace) if !readable { return nil, false, fmt.Errorf("namespace cache is not synchronized") } items, err := indexerItems(entries, namespace) if err != nil { return nil, false, err } if len(items) > limit { return nil, true, nil } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { if u, ok := item.(*unstructured.Unstructured); ok { result = append(result, u) } } return result, false, nil } // GetWatched reads one object only from already-watched informer stores. It // never probes RBAC, starts an informer, or waits for a cache sync. func (d *DynamicResourceCache) GetWatched(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } key := name if namespace != "" { key = namespace + "/" + name } item, found, err := getByKeyFromEntries(d.entriesForGVR(gvr), key) if err != nil { return nil, fmt.Errorf("failed to get resource: %w", err) } if !found { return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, key) } u, ok := item.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("unexpected type in cache") } return StripUnstructuredFields(u), nil } // ListNamespaces returns resources of gvr unioned across an explicit set of // namespaces. This is the sanctioned multi-namespace path — callers with a // known, RBAC-filtered namespace set use it instead of List(gvr, ""), so the // union is driven by the caller's explicit list rather than by whatever // per-namespace informers incidentally exist. Namespace-scoped informers hold // disjoint namespaces, so the union cannot duplicate an object. // // Two cases short-circuit to a cluster-wide read: an empty/nil set ("all"), // and a cluster-scoped resource (no namespace dimension — a per-namespace // filter would match nothing, so always read it cluster-wide regardless of the // requested namespaces). The cluster-scoped check makes this safe to call // uniformly over a mix of namespaced and cluster-scoped GVRs. func (d *DynamicResourceCache) ListNamespaces(gvr schema.GroupVersionResource, namespaces []string) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if len(namespaces) == 0 || !d.gvrIsNamespaced(gvr) { return d.List(gvr, "") } result := make([]*unstructured.Unstructured, 0) for _, ns := range namespaces { items, err := d.List(gvr, ns) if err != nil { return nil, err } result = append(result, items...) } return result, nil } // ListBlocking returns all resources, waiting for cache sync first. timeout // bounds the sync wait as a whole, however many informers serve the read. It // doesn't cover starting a watch that isn't running yet, which can wait for // CRD discovery and scope probes. func (d *DynamicResourceCache) ListBlocking(gvr schema.GroupVersionResource, namespace string, timeout time.Duration) ([]*unstructured.Unstructured, error) { return d.ListBlockingContext(context.Background(), gvr, namespace, timeout) } // ListBlockingContext is ListBlocking with ctx also bounding this caller's // wait. Cancelling ctx ends the wait, not the shared watch. As with // ListBlocking, a wait that ends before the watch syncs returns whatever the // watch holds so far; callers that need a complete read check // IsNamespaceSynced afterwards. func (d *DynamicResourceCache) ListBlockingContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string, timeout time.Duration) ([]*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil { return nil, err } entries := d.readEntries(gvr, namespace) if len(entries) == 0 { return nil, fmt.Errorf("informer not found for %v", gvr) } var pending []cache.InformerSynced for _, e := range entries { if !e.informer.HasSynced() { pending = append(pending, e.informer.HasSynced) } } if len(pending) > 0 { ctx, cancel := context.WithTimeout(ctx, timeout) cache.WaitForCacheSync(ctx.Done(), pending...) cancel() } items, err := indexerItems(entries, namespace) if err != nil { return nil, fmt.Errorf("failed to list resources: %w", err) } result := make([]*unstructured.Unstructured, 0, len(items)) for _, item := range items { if u, ok := item.(*unstructured.Unstructured); ok { result = append(result, StripUnstructuredFields(u)) } } return result, nil } // Get returns a single resource by namespace and name. func (d *DynamicResourceCache) Get(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { return d.get(gvr, namespace, name, false) } // GetPreserveLastApplied returns a single cached resource while preserving the // kubectl last-applied annotation for internal drift computation. func (d *DynamicResourceCache) GetPreserveLastApplied(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { return d.get(gvr, namespace, name, true) } func (d *DynamicResourceCache) get(gvr schema.GroupVersionResource, namespace, name string, preserveLastApplied bool) (*unstructured.Unstructured, error) { if d == nil { return nil, fmt.Errorf("dynamic resource cache not initialized") } if err := d.ensureWatching(gvr, namespace); err != nil { return nil, err } entries := d.readEntries(gvr, namespace) if len(entries) == 0 { return nil, fmt.Errorf("informer not found for %v", gvr) } var key string if namespace != "" { key = namespace + "/" + name } else { key = name } item, found, err := getByKeyFromEntries(entries, key) if err != nil { return nil, fmt.Errorf("failed to get resource: %w", err) } if !found && !entriesSynced(entries) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) for _, e := range entries { cache.WaitForCacheSync(ctx.Done(), e.informer.HasSynced) } cancel() item, found, err = getByKeyFromEntries(entries, key) if err != nil { return nil, fmt.Errorf("failed to get resource: %w", err) } } if !found { return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, key) } u, ok := item.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("unexpected type in cache") } if preserveLastApplied { return StripUnstructuredFieldsPreserveLastApplied(u), nil } return StripUnstructuredFields(u), nil } // ListWithSelector returns resources matching a label selector. func (d *DynamicResourceCache) ListWithSelector(gvr schema.GroupVersionResource, namespace string, selector labels.Selector) ([]*unstructured.Unstructured, error) { items, err := d.List(gvr, namespace) if err != nil { return nil, err } if selector == nil || selector.Empty() { return items, nil } result := make([]*unstructured.Unstructured, 0) for _, item := range items { if selector.Matches(labels.Set(item.GetLabels())) { result = append(result, item) } } return result, nil } // ListDirect fetches resources directly from the API (bypasses cache). func (d *DynamicResourceCache) ListDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) { items, _, err := d.ListDirectSelected(ctx, gvr, namespace, "", 0) return items, err } // ListDirectSelected fetches one page of resources matching labelSelector // directly from the API (bypasses cache). A limit of 0 means no limit. // truncated reports that the API server returned a continue token, i.e. more // matching objects exist beyond the returned page. func (d *DynamicResourceCache) ListDirectSelected(ctx context.Context, gvr schema.GroupVersionResource, namespace, labelSelector string, limit int64) (items []*unstructured.Unstructured, truncated bool, err error) { opts := metav1.ListOptions{LabelSelector: labelSelector, Limit: limit} var list *unstructured.UnstructuredList if namespace != "" { list, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, opts) } else { list, err = d.config.DynamicClient.Resource(gvr).List(ctx, opts) } if err != nil { return nil, false, fmt.Errorf("failed to list resources: %w", err) } result := make([]*unstructured.Unstructured, len(list.Items)) for i := range list.Items { result[i] = StripUnstructuredFields(&list.Items[i]) } return result, list.GetContinue() != "", nil } // GetDirect fetches a single resource directly from the API (bypasses cache). func (d *DynamicResourceCache) GetDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { return d.getDirect(ctx, gvr, namespace, name, false) } // GetDirectPreserveLastApplied fetches a single resource directly from the // API while preserving kubectl.kubernetes.io/last-applied-configuration. Used // exclusively by GitOps drift detection, which needs the annotation to diff // declared vs live state. Bypasses the dynamic informer entirely on purpose: // caching this code path would otherwise force-start an informer for the // resource's GVR (often core kinds like apps/Deployment, /v1/Service that // Argo's status.resources references) and retain last-applied across every // object cluster-wide — a meaningful memory regression to power a per-page // diagnostic. Direct GET pays one API round-trip per managed resource per // insight build (memoized 5s upstream). func (d *DynamicResourceCache) GetDirectPreserveLastApplied(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { return d.getDirect(ctx, gvr, namespace, name, true) } func (d *DynamicResourceCache) getDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string, preserveLastApplied bool) (*unstructured.Unstructured, error) { var u *unstructured.Unstructured var err error if namespace != "" { u, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) } else { u, err = d.config.DynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) } if err != nil { return nil, err } if preserveLastApplied { return StripUnstructuredFieldsPreserveLastApplied(u), nil } return StripUnstructuredFields(u), nil } // --------------------------------------------------------------------------- // Batch / warmup // --------------------------------------------------------------------------- // WarmupParallel starts watching multiple resources in parallel and waits for all to sync. func (d *DynamicResourceCache) WarmupParallel(gvrs []schema.GroupVersionResource, timeout time.Duration) { if d == nil || len(gvrs) == 0 { return } type probeResult struct { gvr schema.GroupVersionResource scopes []string complete bool err error outcome probeOutcome } results := make(chan probeResult, len(gvrs)) for _, gvr := range gvrs { go func(g schema.GroupVersionResource) { o := d.walkScopes(g, "") results <- probeResult{gvr: g, scopes: o.scopes, complete: o.complete, err: o.err, outcome: o} }(gvr) } var started []informerKey for range gvrs { r := <-results if r.err != nil { switch { case d.retainAuthProbeFailure(r.gvr, "", r.complete, r.err): case errors.Is(r.err, errScopeProbeIncomplete): d.retainObservation(r.gvr, DynamicObservationDeferred, "scope_probe_incomplete", true) default: d.retainObservation(r.gvr, DynamicObservationDeferred, "scope_probe_failed", false) } d.noteWalk(r.gvr, r.outcome) continue } allStarted := true for _, scope := range r.scopes { if err := d.startWatching(r.gvr, scope); err == nil { started = append(started, informerKey{gvr: r.gvr, ns: scope}) } else { allStarted = false truncated := len(r.scopes) > 0 && r.scopes[0] != "" && (!r.complete || d.config.NamespaceFallbacksTruncated) d.retainObservation(r.gvr, DynamicObservationDeferred, "watch_start_failed", truncated) } } if !allStarted { r.outcome.fullWalk = false } d.noteWalk(r.gvr, r.outcome) } if len(started) == 0 { return } d.mu.RLock() syncFuncs := make([]cache.InformerSynced, 0, len(started)) for _, key := range started { if e, ok := d.informers[key]; ok { syncFuncs = append(syncFuncs, e.informer.HasSynced) } } d.mu.RUnlock() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if !cache.WaitForCacheSync(ctx.Done(), syncFuncs...) { log.Printf("Warning: not all dynamic caches synced within timeout") } else { log.Printf("All %d dynamic resources synced", len(syncFuncs)) } } // DiscoverAllCRDs discovers all CRDs that support list/watch and decides which // to watch eagerly vs on-demand. Known integrations (cert-manager, KEDA, etc.) // are already watching from WarmupCommonCRDs. For the rest, CRDs with ≤100 // resources are watched eagerly (cheap, full timeline coverage). CRDs with >100 // resources (calico, cilium, etc.) are deferred to on-demand via EnsureWatching() // when the user browses them, avoiding expensive watch connections. // // Once the startup pass is done, CRDs installed later get the same decision: // see watchLateCRDs. func (d *DynamicResourceCache) DiscoverAllCRDs() { if d == nil { log.Println("[CRD Discovery] Cache is nil, skipping") return } d.discoveryMu.Lock() if d.discoveryStatus != CRDDiscoveryIdle { log.Printf("[CRD Discovery] Already in status: %s, skipping", d.discoveryStatus) d.discoveryMu.Unlock() return } d.discoveryStatus = CRDDiscoveryInProgress d.discoveryMu.Unlock() log.Println("[CRD Discovery] Starting CRD discovery...") go func() { defer func() { panicked := false if r := recover(); r != nil { panicked = true buf := make([]byte, 4096) n := runtime.Stack(buf, false) log.Printf("PANIC in CRD discovery goroutine: %v\n%s", r, buf[:n]) } d.discoveryMu.Lock() if d.discoveryStatus != CRDDiscoveryComplete { d.discoveryStatus = CRDDiscoveryComplete close(d.discoveryDone) } d.discoveryMu.Unlock() if panicked { log.Println("[CRD Discovery] CRD discovery terminated due to panic (marked complete to unblock waiters)") } else { log.Println("[CRD Discovery] CRD discovery complete") d.watchLateCRDs() } d.notifyCRDDiscoveryComplete() }() if d.config.Discovery == nil { log.Println("Resource discovery not available for CRD discovery") return } gvrs, keys, err := d.crdWatchCandidates() if err != nil { log.Printf("Failed to get API resources for CRD discovery: %v", err) return } d.markCRDsSeen(keys) if len(gvrs) == 0 { log.Println("No watchable CRDs found") return } stats := d.watchSmallCRDs(gvrs) if stats.eager+stats.deferred+stats.noAccess == 0 { log.Printf("Discovered %d watchable CRDs (all %d already watching from warmup)", len(gvrs), stats.alreadyWatching) return } log.Printf("Discovered %d watchable CRDs (%d already watching, %d small → eager, %d large → on-demand, %d no access)", len(gvrs), stats.alreadyWatching, stats.eager, stats.deferred, stats.noAccess) }() } // crdWatchCandidates returns one GVR per CRD (its most stable served version) // for every CRD that supports list and watch, and the group/resource key of // every CRD discovery reports, watchable or not. func (d *DynamicResourceCache) crdWatchCandidates() ([]schema.GroupVersionResource, []string, error) { resources, err := d.config.Discovery.GetAPIResources() if err != nil { return nil, nil, err } best := make(map[string]schema.GroupVersionResource) allKeys := make(map[string]struct{}) for _, res := range resources { if !res.IsCRD { continue } allKeys[res.Group+"/"+res.Name] = struct{}{} gvr := schema.GroupVersionResource{ Group: res.Group, Version: res.Version, Resource: res.Name, } // Crossplane serves this legacy API with a deprecation warning on every // informer watch renewal. Keep it available on demand, but don't eagerly watch it. if res.Group == "apiextensions.crossplane.io" && res.Name == "usages" { d.retainObservation(gvr, DynamicObservationDeferred, "legacy_api_deferred", false) continue } hasList := false hasWatch := false for _, verb := range res.Verbs { if verb == "list" { hasList = true } if verb == "watch" { hasWatch = true } } if !hasList || !hasWatch { continue } key := res.Group + "/" + res.Name if existing, ok := best[key]; ok { if !IsMoreStableVersion(res.Version, existing.Version) { continue } } best[key] = gvr } gvrs := make([]schema.GroupVersionResource, 0, len(best)) for _, gvr := range best { gvrs = append(gvrs, gvr) } keys := make([]string, 0, len(allKeys)) for key := range allKeys { keys = append(keys, key) } return gvrs, keys, nil } type crdWatchStats struct { alreadyWatching, eager, deferred, noAccess int retry []schema.GroupVersionResource } // maxEagerResources is the object count up to which a CRD is watched eagerly. // CRDs with few resources are cheap to watch and give full timeline coverage; // CRDs with many (calico policies, cilium endpoints) are deferred to on-demand. const maxEagerResources = 100 // watchSmallCRDs probes each GVR that isn't watched yet and starts watching // the ones with at most maxEagerResources objects. func (d *DynamicResourceCache) watchSmallCRDs(gvrs []schema.GroupVersionResource) crdWatchStats { var stats crdWatchStats d.mu.RLock() watched := make(map[schema.GroupVersionResource]struct{}, len(d.informers)) for k := range d.informers { watched[k.gvr] = struct{}{} } stats.alreadyWatching = len(watched) var remaining []schema.GroupVersionResource for _, gvr := range gvrs { if _, exists := watched[gvr]; !exists { remaining = append(remaining, gvr) } } d.mu.RUnlock() if len(remaining) == 0 { return stats } type probeResult struct { gvr schema.GroupVersionResource count int // -1 = no access err error } results := make(chan probeResult, len(remaining)) for _, gvr := range remaining { go func(g schema.GroupVersionResource) { defer func() { if r := recover(); r != nil { log.Printf("[CRD Discovery] Panic probing %s.%s/%s: %v", g.Resource, g.Group, g.Version, r) results <- probeResult{gvr: g, count: -2} } }() count, err := d.probeCount(g) results <- probeResult{gvr: g, count: count, err: err} }(gvr) } var eager []schema.GroupVersionResource for range remaining { r := <-results if r.count == -1 { stats.noAccess++ d.retainAuthProbeFailure(r.gvr, "", true, r.err) continue } if r.count == -2 { // Probe failed (timeout, network error) — defer to be safe stats.deferred++ stats.retry = append(stats.retry, r.gvr) d.retainObservation(r.gvr, DynamicObservationDeferred, "resource_count_probe_failed", false) continue } if r.count <= maxEagerResources { eager = append(eager, r.gvr) d.retainObservation(r.gvr, DynamicObservationDeferred, "eager_watch_pending", false) } else { stats.deferred++ d.retainObservation(r.gvr, DynamicObservationDeferred, "resource_count_exceeds_eager_limit", false) if d.config.DebugEvents { kind := d.gvrToKind(r.gvr) log.Printf("[CRD Discovery] Deferring %s (%d resources > %d threshold)", kind, r.count, maxEagerResources) } } } stats.eager = len(eager) if len(eager) > 0 { d.WarmupParallel(eager, 30*time.Second) } return stats } // --------------------------------------------------------------------------- // CRDs installed after startup discovery // --------------------------------------------------------------------------- var crdGVR = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} // lateCRDScanDelay batches the CRD events of one install (an operator chart // usually creates many CRDs at once, and each becomes Established in a later // update) into a single discovery refresh. A scan that leaves a new CRD // unresolved (discovery failed or doesn't serve it yet) is retried with // doubling delays, up to lateCRDMaxRetries times. var lateCRDScanDelay = 3 * time.Second const lateCRDMaxRetries = 5 // markCRDsSeen records CRDs (group/resource keys) that already went through a // watch decision and returns the ones not seen before. Seen CRDs are no longer // pending a scan. func (d *DynamicResourceCache) markCRDsSeen(keys []string) map[string]struct{} { d.lateCRDMu.Lock() defer d.lateCRDMu.Unlock() fresh := make(map[string]struct{}) for _, key := range keys { delete(d.pendingCRDs, key) if _, ok := d.seenCRDs[key]; ok { continue } d.seenCRDs[key] = struct{}{} fresh[key] = struct{}{} } return fresh } // watchLateCRDs subscribes to CRD events so CRDs installed after startup get // the same watch decision as DiscoverAllCRDs gave the ones already present, // and their resources' changes reach the timeline from install on. func (d *DynamicResourceCache) watchLateCRDs() { handler := cache.ResourceEventHandlerFuncs{ AddFunc: func(obj any) { d.onCRDEvent(obj) }, UpdateFunc: func(_, obj any) { d.onCRDEvent(obj) }, DeleteFunc: func(obj any) { d.forgetCRD(obj) }, } if err := d.AddGVRChangeHandler(crdGVR, handler); err == nil { return } if err := d.EnsureWatching(crdGVR); err != nil { log.Printf("[CRD Discovery] Not watching CRDs, so CRDs installed later are watched only when listed: %v", err) return } if err := d.AddGVRChangeHandler(crdGVR, handler); err != nil { log.Printf("[CRD Discovery] Not watching CRDs, so CRDs installed later are watched only when listed: %v", err) } } // crdKey returns the group/resource key of a CRD object, as used in seenCRDs. func crdKey(obj any) (string, bool) { if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { obj = tombstone.Obj } u, ok := obj.(*unstructured.Unstructured) if !ok { return "", false } group, _, _ := unstructured.NestedString(u.Object, "spec", "group") plural, _, _ := unstructured.NestedString(u.Object, "spec", "names", "plural") if group == "" || plural == "" { return "", false } return group + "/" + plural, true } // onCRDEvent schedules a scan for a CRD that hasn't had a watch decision yet. // Updates to known CRDs (cert-manager's cainjector rewrites CRD CA bundles // regularly) don't trigger one: a scan refreshes discovery, which is costly. func (d *DynamicResourceCache) onCRDEvent(obj any) { key, ok := crdKey(obj) if !ok { return } d.lateCRDMu.Lock() defer d.lateCRDMu.Unlock() if _, seen := d.seenCRDs[key]; seen { return } if _, pending := d.pendingCRDs[key]; !pending { // A newly pending CRD gets the full retry budget, even if it arrives // while earlier CRDs are on their last retry. d.pendingCRDs[key] = struct{}{} d.lateCRDRetries = 0 } d.scheduleLateCRDScanLocked(lateCRDScanDelay) } // scheduleLateCRDScanLocked starts a scan after delay unless one is already // scheduled or running; a running scan reschedules itself while CRDs remain // pending. Caller holds lateCRDMu. func (d *DynamicResourceCache) scheduleLateCRDScanLocked(delay time.Duration) { if d.lateCRDTimer != nil || d.lateCRDScanning { return } d.lateCRDTimer = time.AfterFunc(delay, d.runLateCRDScan) } func (d *DynamicResourceCache) runLateCRDScan() { d.lateCRDMu.Lock() d.lateCRDTimer = nil d.lateCRDScanning = true d.lateCRDMu.Unlock() d.scanLateCRDs() d.lateCRDMu.Lock() defer d.lateCRDMu.Unlock() d.lateCRDScanning = false if d.isStopped() || len(d.pendingCRDs) == 0 { d.lateCRDRetries = 0 return } if d.lateCRDRetries >= lateCRDMaxRetries { log.Printf("[CRD Discovery] %d new CRDs still not served after %d retries; they'll be watched when listed", len(d.pendingCRDs), d.lateCRDRetries) clear(d.pendingCRDs) d.lateCRDRetries = 0 return } d.lateCRDRetries++ d.scheduleLateCRDScanLocked(lateCRDScanDelay << d.lateCRDRetries) } func (d *DynamicResourceCache) isStopped() bool { d.mu.RLock() defer d.mu.RUnlock() return d.stopped } // forgetCRD lets a deleted CRD be handled again if it's reinstalled. func (d *DynamicResourceCache) forgetCRD(obj any) { key, ok := crdKey(obj) if !ok { return } d.lateCRDMu.Lock() delete(d.seenCRDs, key) delete(d.pendingCRDs, key) d.lateCRDMu.Unlock() } func (d *DynamicResourceCache) scanLateCRDs() { if d.isStopped() || d.config.Discovery == nil { return } // A new CRD isn't in the cached API resource list until discovery refreshes. if err := d.config.Discovery.Refresh(); err != nil { log.Printf("[CRD Discovery] Refresh after CRD change failed: %v", err) return } if d.isStopped() { return } gvrs, keys, err := d.crdWatchCandidates() if err != nil { log.Printf("[CRD Discovery] Listing CRDs after CRD change failed: %v", err) return } newKeys := d.markCRDsSeen(keys) var fresh []schema.GroupVersionResource for _, gvr := range gvrs { if _, ok := newKeys[gvr.Group+"/"+gvr.Resource]; ok { fresh = append(fresh, gvr) } } if len(fresh) == 0 { return } stats := d.watchSmallCRDs(fresh) d.lateCRDMu.Lock() for _, gvr := range stats.retry { key := gvr.Group + "/" + gvr.Resource if _, seen := d.seenCRDs[key]; seen { delete(d.seenCRDs, key) d.pendingCRDs[key] = struct{}{} } } d.lateCRDMu.Unlock() // A reinstalled CRD can still have the informer from before its deletion. alreadyWatched := len(fresh) - stats.eager - stats.deferred - stats.noAccess log.Printf("[CRD Discovery] %d CRDs installed after startup (%d small → eager, %d large → on-demand, %d no access, %d already watched)", len(fresh), stats.eager, stats.deferred, stats.noAccess, alreadyWatched) } // --------------------------------------------------------------------------- // Discovery status / sync // --------------------------------------------------------------------------- // GetDiscoveryStatus returns the current CRD discovery status. func (d *DynamicResourceCache) GetDiscoveryStatus() CRDDiscoveryStatus { if d == nil { return CRDDiscoveryIdle } d.discoveryMu.RLock() defer d.discoveryMu.RUnlock() return d.discoveryStatus } // entriesForGVR returns every informer entry watching gvr across all scopes — // the cluster-wide entry and/or per-namespace entries. GVR-level status and // handler-registration APIs ask "is this kind watched?" rather than returning // resource data, so they span all of a GVR's namespace-scoped informers — // unlike readEntries, whose ns == "" path is cluster-wide-only to keep data // reads deterministic. Using readEntries(gvr, "") here would wrongly report a // namespace-restricted (no cluster-wide informer) GVR as unwatched/unsynced. func (d *DynamicResourceCache) entriesForGVR(gvr schema.GroupVersionResource) []*informerEntry { d.mu.RLock() defer d.mu.RUnlock() var out []*informerEntry for k, e := range d.informers { if k.gvr == gvr { out = append(out, e) } } return out } // WaitForSync waits for a resource's cache(s) to be synced (with timeout). // A GVR may have several namespace-scoped informers; all must sync. func (d *DynamicResourceCache) WaitForSync(gvr schema.GroupVersionResource, timeout time.Duration) bool { entries := d.entriesForGVR(gvr) if len(entries) == 0 { return false } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() syncFuncs := make([]cache.InformerSynced, 0, len(entries)) for _, e := range entries { syncFuncs = append(syncFuncs, e.informer.HasSynced) } return cache.WaitForCacheSync(ctx.Done(), syncFuncs...) } // WaitForSyncContext waits, at most until ctx ends, for the watches covering // namespace to sync, and reports whether they did. func (d *DynamicResourceCache) WaitForSyncContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) bool { entries := d.readEntries(gvr, namespace) if len(entries) == 0 { return false } syncFuncs := make([]cache.InformerSynced, 0, len(entries)) for _, e := range entries { syncFuncs = append(syncFuncs, e.informer.HasSynced) } return cache.WaitForCacheSync(ctx.Done(), syncFuncs...) } // IsSynced checks if a resource's cache(s) are synced (non-blocking). It // reports on the informers that EXIST — an unsettled fallback fanout can be // synced-but-partial. Callers asserting completeness or absence must use // IsClusterWideSynced (or Count, which refuses unsettled fanouts) instead. func (d *DynamicResourceCache) IsSynced(gvr schema.GroupVersionResource) bool { return d.servedSynced(d.entriesForGVR(gvr)) } // WatchedSince returns when the cache started watching gvr: the latest start // among its informers, so every change after it was delivered. ok is false // when gvr isn't watched or an informer hasn't synced yet. func (d *DynamicResourceCache) WatchedSince(gvr schema.GroupVersionResource) (since time.Time, ok bool) { return d.watchedSince(d.entriesForGVR(gvr)) } // NamespaceWatchedSince is WatchedSince for only the informers serving namespace. // An empty namespace checks every informer. An unwatched or unsynced scope // returns ok=false; that is unknown coverage, never a zero-time boundary. func (d *DynamicResourceCache) NamespaceWatchedSince(gvr schema.GroupVersionResource, namespace string) (time.Time, bool) { if namespace == "" { return d.WatchedSince(gvr) } return d.watchedSince(d.readEntries(gvr, namespace)) } func (d *DynamicResourceCache) watchedSince(entries []*informerEntry) (since time.Time, ok bool) { if !d.servedSynced(entries) { return time.Time{}, false } for _, e := range entries { if e.startedAt.After(since) { since = e.startedAt } } return since, true } // AllNamespacesFanoutIncomplete reports that the last all-namespaces walk for // gvr across the fallback namespaces ended early (its deadline hit mid-walk), // so a read across every namespace can miss some of them even once the // informers that did start have synced. The next all-namespaces read resumes // the walk. func (d *DynamicResourceCache) AllNamespacesFanoutIncomplete(gvr schema.GroupVersionResource) bool { if d == nil || d.config.NamespaceScoped { return false } d.mu.RLock() defer d.mu.RUnlock() return d.fanoutIncomplete[gvr] } // IsClusterWideSynced reports whether gvr is served by a single cluster-wide // informer that has finished syncing. When true, ListWatched(gvr) is // authoritative for EVERY namespace — the caller can safely conclude an object // is absent cluster-wide. When false (namespace-scoped fallback, unsynced, or // unwatched), the cache may only know a subset of namespaces, so "not found" // must NOT be treated as "doesn't exist". The "never both" informer invariant // (a GVR has either one cluster-wide informer or namespaced ones, never both) // makes the cluster-wide check authoritative. func (d *DynamicResourceCache) IsClusterWideSynced(gvr schema.GroupVersionResource) bool { if d == nil { return false } // Explicitly require the cluster-wide informer — hasCoveringInformer // also reports true for a resolved namespace-fallback fanout, whose // coverage is NOT cluster-wide (callers use this to assert absence). d.mu.RLock() _, clusterWide := d.informers[informerKey{gvr: gvr}] d.mu.RUnlock() return clusterWide && d.IsSynced(gvr) } // IsNamespaceSynced reports whether the cache can answer authoritatively for // this GVR *in this namespace*. // // IsSynced spans every informer for the GVR, so it answers "is some informer // synced" — true as soon as any one namespace has been watched. A caller reading // a different namespace would pass that gate, start a fresh informer on the // first List, and read an empty indexer with no error: an absence the cache // never established. This asks the question the read actually depends on — the // cluster-wide informer if there is one, otherwise this namespace's. // // An empty namespace means a cluster-wide read; use IsClusterWideSynced for // that, which this defers to. func (d *DynamicResourceCache) IsNamespaceSynced(gvr schema.GroupVersionResource, namespace string) bool { if d == nil { return false } if namespace == "" { return d.IsClusterWideSynced(gvr) } // readEntries selects exactly the informers that would serve this read — the // cluster-wide one if it exists, otherwise this namespace's, and nothing when // neither does. entriesSynced then asks each informer directly rather than // reading the bookkeeping flag, which the watch goroutine sets a moment later // and which therefore reports a synced informer as unsynced. return d.servedSynced(d.readEntries(gvr, namespace)) } // servedEntries returns the informers serving reads of gvr in namespace now and // whether they answer authoritatively, by IsNamespaceSynced's rules. Both come // from the same informers, so a replacement between them cannot mix them. func (d *DynamicResourceCache) servedEntries(gvr schema.GroupVersionResource, namespace string) ([]*informerEntry, bool) { if d == nil { return nil, false } d.mu.RLock() clusterWide, ok := d.informers[informerKey{gvr: gvr}] d.mu.RUnlock() if ok { entries := []*informerEntry{clusterWide} return entries, d.servedSynced(entries) } entries := d.readEntries(gvr, namespace) if namespace == "" { // Only the cluster-wide informer answers a cluster-wide read. return entries, false } return entries, d.servedSynced(entries) } // ReadableObject gets one object from the informers serving gvr in namespace // now ("" for a cluster-scoped resource), without probing, starting a watch or // waiting for one. found reports whether they hold it. ok is true only if they // had completed their initial list before the read; when ok is false, a miss // proves nothing. // // The object is the cached one, not a copy: callers must not modify it or keep // it beyond the computation that read it. func (d *DynamicResourceCache) ReadableObject(gvr schema.GroupVersionResource, namespace, name string) (obj *unstructured.Unstructured, found, ok bool) { entries, ok := d.servedEntries(gvr, namespace) if !ok { return nil, false, false } key := name if namespace != "" { key = namespace + "/" + name } item, found, err := getByKeyFromEntries(entries, key) if err != nil { return nil, false, false } if !found { return nil, false, true } u, isUnstructured := item.(*unstructured.Unstructured) return u, isUnstructured, isUnstructured } // --------------------------------------------------------------------------- // Introspection // --------------------------------------------------------------------------- // GetWatchedResources returns a list of GVRs currently being watched. func (d *DynamicResourceCache) GetWatchedResources() []schema.GroupVersionResource { if d == nil { return nil } d.mu.RLock() defer d.mu.RUnlock() return d.distinctGVRsLocked() } // GetInformerCount returns the number of active dynamic informers. func (d *DynamicResourceCache) GetInformerCount() int { if d == nil { return 0 } d.mu.RLock() defer d.mu.RUnlock() return len(d.informers) } // AddGVRChangeHandler registers a change handler on the informer for the // given GVR. The handler fires for add/update/delete events (including the // initial sync). Returns an error if no informer exists yet for the GVR — // callers should warm up or EnsureWatching the resource before registering. // // Used by derived caches (PolicyReport index, etc.) that need to react to // changes on a single resource kind without subscribing to the global // OnChange callback (which would fire for every dynamic resource). // // The handler runs on the informer's event-processing goroutine; it must // be non-blocking. A panic in the handler is contained by the upstream // informer machinery (no impact on other handlers). func (d *DynamicResourceCache) AddGVRChangeHandler(gvr schema.GroupVersionResource, handler cache.ResourceEventHandler) error { if d == nil { return fmt.Errorf("dynamic resource cache not initialized") } d.mu.Lock() defer d.mu.Unlock() var entries []*informerEntry for k, e := range d.informers { if k.gvr == gvr { entries = append(entries, e) } } if len(entries) == 0 { return fmt.Errorf("no informer for %s.%s/%s; warm up the resource before registering a handler", gvr.Resource, gvr.Group, gvr.Version) } // Remember the handler so informers started later for this GVR (lazy // per-namespace watches, or re-creations after an idle reap) get it too — // otherwise derived caches silently miss those namespaces' events. d.gvrHandlers[gvr] = append(d.gvrHandlers[gvr], handler) for _, e := range entries { if _, err := e.informer.AddEventHandler(handler); err != nil { return fmt.Errorf("add event handler for %v: %w", gvr, err) } } return nil } // --------------------------------------------------------------------------- // CRD discovery callbacks // --------------------------------------------------------------------------- // OnCRDDiscoveryComplete registers a callback to be called when CRD discovery completes. func (d *DynamicResourceCache) OnCRDDiscoveryComplete(callback func()) { d.crdCallbacksMu.Lock() defer d.crdCallbacksMu.Unlock() d.crdCallbacks = append(d.crdCallbacks, callback) } func (d *DynamicResourceCache) notifyCRDDiscoveryComplete() { d.crdCallbacksMu.RLock() defer d.crdCallbacksMu.RUnlock() for _, cb := range d.crdCallbacks { go cb() } } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- // IsStopped reports whether Stop has been called. A stopped cache's answers // belong to the connection it was built for, so a reader that held it across // a context switch must not report them as current. func (d *DynamicResourceCache) IsStopped() bool { d.mu.RLock() defer d.mu.RUnlock() return d.stopped } // Stop initiates a non-blocking shutdown of the dynamic cache. func (d *DynamicResourceCache) Stop() { if d == nil { return } d.stopOnce.Do(func() { log.Println("Stopping dynamic resource cache") d.mu.Lock() d.stopped = true d.stopProbes() d.mu.Unlock() d.discoveryMu.Lock() if d.discoveryStatus != CRDDiscoveryComplete { d.discoveryStatus = CRDDiscoveryComplete close(d.discoveryDone) } d.discoveryMu.Unlock() d.lateCRDMu.Lock() if d.lateCRDTimer != nil { d.lateCRDTimer.Stop() d.lateCRDTimer = nil } d.lateCRDMu.Unlock() // Closing stopCh cancels every per-informer context (each informer's // watchdog goroutine selects on it), stopping all watches. close(d.stopCh) d.mu.RLock() nsFactories := make([]dynamicinformer.DynamicSharedInformerFactory, 0, len(d.nsFactories)) for _, f := range d.nsFactories { nsFactories = append(nsFactories, f) } d.mu.RUnlock() go func() { done := make(chan struct{}) go func() { d.factory.Shutdown() for _, f := range nsFactories { f.Shutdown() } close(done) }() select { case <-done: log.Println("Dynamic resource cache factory shutdown complete") case <-time.After(5 * time.Second): log.Println("Dynamic resource cache factory shutdown taking >5s, abandoning (goroutine will finish on its own)") } }() }) }