package k8score import ( "context" "errors" "reflect" "sync/atomic" "testing" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" dynamicfake "k8s.io/client-go/dynamic/fake" k8stesting "k8s.io/client-go/testing" ) func TestDynamicResourceObservationUnwatchedAndUnsupported(t *testing.T) { watchable := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} unsupported := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "summaries"} discovery := &ResourceDiscovery{ resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), } discovery.AddAPIResource(APIResource{ Group: watchable.Group, Version: watchable.Version, Kind: "Widget", Name: watchable.Resource, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) discovery.AddAPIResource(APIResource{ Group: unsupported.Group, Version: unsupported.Version, Kind: "Summary", Name: unsupported.Resource, IsCRD: true, Verbs: []string{"get", "list"}, }) dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ watchable: "WidgetList", }) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) for _, test := range []struct { name string gvr schema.GroupVersionResource state DynamicObservationState reason string }{ {name: "unwatched", gvr: watchable, state: DynamicObservationUnwatched, reason: "not_observed"}, {name: "unsupported", gvr: unsupported, state: DynamicObservationUnsupported, reason: "list_watch_unsupported"}, } { t.Run(test.name, func(t *testing.T) { got := cache.Observation(test.gvr) if got.State != test.state || got.ReasonCode != test.reason { t.Fatalf("observation = %+v, want state=%s reason=%s", got, test.state, test.reason) } }) } if err := cache.EnsureWatching(unsupported); !errors.Is(err, ErrCollectionNotSupported) { t.Fatalf("unsupported GVR cause = %v", err) } discovery.AddAPIResource(APIResource{ Group: unsupported.Group, Version: unsupported.Version, Kind: "Summary", Name: unsupported.Resource, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) if got := cache.Observation(unsupported); got.State != DynamicObservationUnwatched || got.ReasonCode != "not_observed" { t.Fatalf("refreshed watchable GVR retained unsupported state: %+v", got) } if actions := dyn.Actions(); len(actions) != 0 { t.Fatalf("read-only observations touched the API server: %v", actions) } } func TestDynamicResourceObservationTracksOnDemandSyncAndExactGVR(t *testing.T) { observed := schema.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"} colliding := schema.GroupVersionResource{Group: "networking.istio.io", Version: "v1", Resource: "gateways"} discovery := &ResourceDiscovery{ resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), } for _, gvr := range []schema.GroupVersionResource{observed, colliding} { discovery.AddAPIResource(APIResource{ Group: gvr.Group, Version: gvr.Version, Kind: "Gateway", Name: gvr.Resource, Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) } dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ observed: "GatewayList", colliding: "GatewayList", }) releaseSync := make(chan struct{}) var observedLists atomic.Int32 dyn.PrependReactor("list", "gateways", func(action k8stesting.Action) (bool, runtime.Object, error) { if action.GetResource() != observed { return false, nil, nil } if observedLists.Add(1) > 1 { <-releaseSync } return false, nil, nil }) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) if err := cache.EnsureWatching(observed); err != nil { t.Fatalf("EnsureWatching: %v", err) } syncing := cache.Observation(observed) if syncing.State != DynamicObservationSyncing { t.Fatalf("syncing observation = %+v", syncing) } if syncing.Scope != DynamicObservationScopeCluster || syncing.ReasonCode != "initial_sync" { t.Fatalf("syncing scope/reason = %+v", syncing) } if got := cache.Observation(colliding); got.State != DynamicObservationUnwatched { t.Fatalf("same-named GVR inherited observation from another group: %+v", got) } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) started := time.Now() _, waitErr := cache.ListBlockingContext(ctx, observed, "", 3*time.Second) cancel() if waitErr != nil && !errors.Is(waitErr, context.DeadlineExceeded) { t.Fatalf("cancelled caller wait: %v", waitErr) } if time.Since(started) > 2*time.Second || cache.Observation(observed).State != DynamicObservationSyncing { t.Fatal("caller cancellation did not bound the wait independently of the shared watch") } close(releaseSync) if !cache.WaitForSync(observed, 3*time.Second) { t.Fatal("on-demand informer did not sync") } watched := cache.Observation(observed) if watched.State != DynamicObservationSynced || watched.ReasonCode != "informer_synced" { t.Fatalf("watched observation = %+v", watched) } if cache.IsStopped() { t.Fatal("running cache reported stopped") } cache.Stop() if !cache.IsStopped() { t.Fatal("stopped cache reported running") } if got := cache.Observation(observed); got.State != DynamicObservationUnwatched || got.ReasonCode != "cache_stopped" { t.Fatalf("stopped cache retained active observation: %+v", got) } } func TestDynamicResourceObservationRetainsDeniedAndDeferredOutcomes(t *testing.T) { deniedGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "deniedwidgets"} deniedDiscovery := &ResourceDiscovery{ resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), lastRefresh: time.Now(), cacheTTL: time.Hour, } deniedDiscovery.AddAPIResource(APIResource{ Group: deniedGVR.Group, Version: deniedGVR.Version, Kind: "DeniedWidget", Name: deniedGVR.Resource, Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) deniedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool { return false }) deniedCache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: deniedClient, Discovery: deniedDiscovery}) if err != nil { t.Fatalf("NewDynamicResourceCache denied: %v", err) } t.Cleanup(deniedCache.Stop) if err := deniedCache.EnsureWatching(deniedGVR); err == nil { t.Fatal("EnsureWatching denied GVR succeeded") } denied := deniedCache.Observation(deniedGVR) if denied.State != DynamicObservationDenied || denied.ReasonCode != "access_denied" { t.Fatalf("denied observation = %+v", denied) } truncatedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool { return false }) truncatedCache, err := NewDynamicResourceCache(DynamicCacheConfig{ DynamicClient: truncatedClient, Discovery: deniedDiscovery, NamespaceFallbacks: []string{"team-a"}, NamespaceFallbacksTruncated: true, }) if err != nil { t.Fatalf("NewDynamicResourceCache truncated denial: %v", err) } t.Cleanup(truncatedCache.Stop) if err := truncatedCache.EnsureWatching(deniedGVR); err == nil { t.Fatal("EnsureWatching truncated denied GVR succeeded") } truncatedDenied := truncatedCache.Observation(deniedGVR) if truncatedDenied.State != DynamicObservationDenied || !truncatedDenied.Truncated || truncatedDenied.ReasonCode != "access_denied_partial_probe" { t.Fatalf("truncated denied observation = %+v", truncatedDenied) } incompleteCandidatesClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{deniedGVR: "DeniedWidgetList"}, func(schema.GroupVersionResource, string) bool { return false }) incompleteCandidatesCache, err := NewDynamicResourceCache(DynamicCacheConfig{ DynamicClient: incompleteCandidatesClient, Discovery: deniedDiscovery, NamespaceFallbacksTruncated: true, }) if err != nil { t.Fatalf("NewDynamicResourceCache incomplete candidates: %v", err) } t.Cleanup(incompleteCandidatesCache.Stop) if err := incompleteCandidatesCache.EnsureWatching(deniedGVR); err == nil { t.Fatal("EnsureWatching incomplete-candidate denied GVR succeeded") } incompleteDenied := incompleteCandidatesCache.Observation(deniedGVR) if incompleteDenied.State != DynamicObservationDenied || !incompleteDenied.Truncated || incompleteDenied.ReasonCode != "access_denied_partial_probe" { t.Fatalf("incomplete-candidate denied observation = %+v", incompleteDenied) } clusterScopedGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "clustersummaries"} clusterScopedDiscovery := &ResourceDiscovery{ resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), lastRefresh: time.Now(), cacheTTL: time.Hour, } clusterScopedDiscovery.AddAPIResource(APIResource{ Group: clusterScopedGVR.Group, Version: clusterScopedGVR.Version, Kind: "ClusterSummary", Name: clusterScopedGVR.Resource, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) clusterScopedClient := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{clusterScopedGVR: "ClusterSummaryList"}, func(schema.GroupVersionResource, string) bool { return false }) clusterScopedCache, err := NewDynamicResourceCache(DynamicCacheConfig{ DynamicClient: clusterScopedClient, Discovery: clusterScopedDiscovery, NamespaceFallbacks: []string{"team-a"}, NamespaceFallbacksTruncated: true, }) if err != nil { t.Fatalf("NewDynamicResourceCache cluster-scoped denial: %v", err) } t.Cleanup(clusterScopedCache.Stop) if err := clusterScopedCache.EnsureWatching(clusterScopedGVR); err == nil { t.Fatal("EnsureWatching denied cluster-scoped GVR succeeded") } clusterScopedDenied := clusterScopedCache.Observation(clusterScopedGVR) if clusterScopedDenied.State != DynamicObservationDenied || clusterScopedDenied.Truncated || clusterScopedDenied.ReasonCode != "access_denied" { t.Fatalf("cluster-scoped denied observation = %+v", clusterScopedDenied) } for _, test := range []struct { name string probeError error panicProbe bool remaining int64 reason string }{ {name: "probe failed", probeError: errors.New("temporary proxy failure"), reason: "resource_count_probe_failed"}, {name: "probe panicked", panicProbe: true, reason: "resource_count_probe_failed"}, {name: "large resource", remaining: 101, reason: "resource_count_exceeds_eager_limit"}, } { t.Run(test.name, func(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} discovery := &ResourceDiscovery{ resources: []APIResource{{ Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource, Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }}, resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), lastRefresh: time.Now(), cacheTTL: time.Hour, } dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) dyn.PrependReactor("list", "widgets", func(k8stesting.Action) (bool, runtime.Object, error) { if test.panicProbe { panic("probe panic") } if test.probeError != nil { return true, nil, test.probeError } list := &unstructured.UnstructuredList{} list.SetRemainingItemCount(&test.remaining) return true, list, nil }) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) cache.DiscoverAllCRDs() select { case <-cache.discoveryDone: case <-time.After(3 * time.Second): t.Fatal("DiscoverAllCRDs did not complete") } got := cache.Observation(gvr) if got.State != DynamicObservationDeferred || got.ReasonCode != test.reason { t.Fatalf("deferred observation = %+v, want reason=%s", got, test.reason) } if test.name == "large resource" { if err := cache.EnsureWatching(gvr); err != nil { t.Fatalf("EnsureWatching deferred GVR: %v", err) } cache.mu.Lock() for key, entry := range cache.informers { if key.gvr == gvr { entry.cancel() delete(cache.informers, key) } } cache.mu.Unlock() if after := cache.Observation(gvr); after.State != DynamicObservationUnwatched { t.Fatalf("successful watch retained stale deferred decision after informer removal: %+v", after) } } }) } } func TestDynamicResourceObservationPreferredNamespaceDenialDoesNotOverwriteGVRState(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} discovery := &ResourceDiscovery{ resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), lastRefresh: time.Now(), cacheTTL: time.Hour, } discovery.AddAPIResource(APIResource{ Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource, Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }) dyn := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{gvr: "WidgetList"}, func(schema.GroupVersionResource, string) bool { return false }) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) cache.retainObservation(gvr, DynamicObservationDeferred, "prior_global_state", false) if err := cache.ensureWatching(gvr, "team-a"); err == nil { t.Fatal("ensureWatching denied preferred namespace succeeded") } got := cache.Observation(gvr) if got.State != DynamicObservationDeferred || got.ReasonCode != "prior_global_state" { t.Fatalf("preferred-namespace denial overwrote GVR observation: %+v", got) } } func TestDynamicResourceObservationTracksWarmupEagerAndNamespaceFanout(t *testing.T) { t.Run("warmup", func(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) cache.WarmupParallel([]schema.GroupVersionResource{gvr}, 3*time.Second) got := cache.Observation(gvr) if got.State != DynamicObservationSynced { t.Fatalf("warmup observation = %+v", got) } }) t.Run("small eager", func(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} discovery := &ResourceDiscovery{ resources: []APIResource{{ Group: gvr.Group, Version: gvr.Version, Kind: "Widget", Name: gvr.Resource, Namespaced: true, IsCRD: true, Verbs: []string{"get", "list", "watch"}, }}, resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource), lastRefresh: time.Now(), cacheTTL: time.Hour, } dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery}) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) cache.DiscoverAllCRDs() select { case <-cache.discoveryDone: case <-time.After(3 * time.Second): t.Fatal("DiscoverAllCRDs did not complete") } got := cache.Observation(gvr) if got.State != DynamicObservationSynced { t.Fatalf("small-eager observation = %+v", got) } }) t.Run("namespace partial truncated", func(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := fakeDynamicForListAccess(t, map[schema.GroupVersionResource]string{gvr: "WidgetList"}, func(_ schema.GroupVersionResource, namespace string) bool { return namespace == "team-a" || namespace == "team-b" }) cache, err := NewDynamicResourceCache(DynamicCacheConfig{ DynamicClient: dyn, NamespaceFallbacks: []string{"team-b", "team-a"}, NamespaceFallbacksTruncated: true, }) if err != nil { t.Fatalf("NewDynamicResourceCache: %v", err) } t.Cleanup(cache.Stop) if err := cache.EnsureWatching(gvr); err != nil { t.Fatalf("EnsureWatching: %v", err) } if !cache.WaitForSync(gvr, 3*time.Second) { t.Fatal("namespace-scoped informers did not sync") } got := cache.Observation(gvr) if got.State != DynamicObservationSynced || got.Scope != DynamicObservationScopeExplicitNamespaces { t.Fatalf("namespace observation = %+v", got) } if !got.Truncated || got.ReasonCode != "namespace_fanout_truncated" { t.Fatalf("namespace bounds = %+v", got) } if !reflect.DeepEqual(got.Namespaces, []string{"team-a", "team-b"}) { t.Fatalf("namespaces = %v", got.Namespaces) } }) } func TestDynamicObservationTimeoutDoesNotProveDenial(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) { if a.GetNamespace() == "" { return true, nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("cluster denied")) } return true, nil, context.DeadlineExceeded }) discovery := &ResourceDiscovery{resourceMap: make(map[string]APIResource), gvrMap: make(map[string]schema.GroupVersionResource)} discovery.AddAPIResource(APIResource{Group: gvr.Group, Version: gvr.Version, Name: gvr.Resource, Kind: "Widget", Namespaced: true, IsCRD: true, Verbs: []string{"list", "watch"}}) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, Discovery: discovery, NamespaceFallbacks: []string{"slow"}}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if err := d.EnsureWatching(gvr); err == nil { t.Fatal("incomplete probe unexpectedly succeeded") } got := d.Observation(gvr) if got.State != DynamicObservationDeferred || got.ReasonCode != "scope_probe_incomplete" || !got.Truncated || got.ObservedAt == nil { t.Fatalf("timeout became denial: %+v", got) } } func TestDynamicObservationStalledNamespaceIsNotHiddenByNewerInformer(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) release := make(chan struct{}) defer close(release) dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) { if a.GetNamespace() == "old" { <-release } return false, nil, nil }) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if err := d.startWatching(gvr, "new"); err != nil { t.Fatal(err) } deadline := time.Now().Add(3 * time.Second) for !d.IsNamespaceSynced(gvr, "new") && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if !d.IsNamespaceSynced(gvr, "new") { t.Fatal("new namespace did not sync") } if err := d.startWatching(gvr, "old"); err != nil { t.Fatal(err) } d.mu.Lock() oldStart := time.Now().Add(-time.Minute) d.informers[informerKey{gvr: gvr, ns: "old"}].startedAt = oldStart d.mu.Unlock() got := d.Observation(gvr) if got.State != DynamicObservationSyncing || got.ReasonCode != "sync_stalled" { t.Fatalf("old stalled namespace masked: %+v", got) } } // Per-namespace informers sync independently, so a namespace's observation // comes from its own informer, never from another namespace's. func TestNamespaceObservationReadsOnlyTheServingInformer(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) release := make(chan struct{}) defer close(release) dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) { if a.GetNamespace() != "synced" { <-release } return false, nil, nil }) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, SyncStallAfter: 45 * time.Second}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if err := d.startWatching(gvr, "synced"); err != nil { t.Fatal(err) } deadline := time.Now().Add(3 * time.Second) for !d.IsNamespaceSynced(gvr, "synced") && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if !d.IsNamespaceSynced(gvr, "synced") { t.Fatal("synced namespace did not sync") } for _, ns := range []string{"stalled", "slow", "fresh"} { if err := d.startWatching(gvr, ns); err != nil { t.Fatal(err) } } d.mu.Lock() d.informers[informerKey{gvr: gvr, ns: "stalled"}].startedAt = time.Now().Add(-time.Minute) // Past the 30-second default, inside the configured SyncStallAfter. d.informers[informerKey{gvr: gvr, ns: "slow"}].startedAt = time.Now().Add(-40 * time.Second) d.mu.Unlock() watched := []string{"fresh", "slow", "stalled", "synced"} for _, tc := range []struct { namespace string state DynamicObservationState reason string }{ {"synced", DynamicObservationSynced, "namespace_partial"}, {"stalled", DynamicObservationSyncing, "sync_stalled"}, {"slow", DynamicObservationSyncing, "initial_sync"}, {"fresh", DynamicObservationSyncing, "initial_sync"}, {"unwatched", DynamicObservationUnwatched, "not_observed"}, } { got := d.NamespaceObservation(gvr, tc.namespace) if got.State != tc.state || got.ReasonCode != tc.reason || got.Scope != DynamicObservationScopeExplicitNamespaces || !reflect.DeepEqual(got.Namespaces, watched) { t.Errorf("%s: %+v, want state=%s reason=%s over the kind's namespaces", tc.namespace, got, tc.state, tc.reason) } } if since, ok := d.NamespaceWatchedSince(gvr, "synced"); !ok || since.IsZero() { t.Fatalf("synced namespace lost its watch boundary: %v %v", since, ok) } for _, ns := range []string{"stalled", "unwatched", ""} { if since, ok := d.NamespaceWatchedSince(gvr, ns); ok || !since.IsZero() { t.Fatalf("%q: unavailable boundary = %v %v", ns, since, ok) } } whole := d.Observation(gvr) if whole.State != DynamicObservationSyncing || whole.ReasonCode != "sync_stalled" { t.Fatalf("kind observation must still report its stalled namespace: %+v", whole) } if got := d.NamespaceObservation(gvr, ""); !reflect.DeepEqual(got, whole) { t.Fatalf("all-namespaces observation %+v differs from the kind's %+v", got, whole) } clusterGVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "gadgets"} clusterWide, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{clusterGVR: "GadgetList"})}) if err != nil { t.Fatal(err) } t.Cleanup(clusterWide.Stop) if err := clusterWide.startWatching(clusterGVR, ""); err != nil { t.Fatal(err) } if !clusterWide.WaitForSync(clusterGVR, 3*time.Second) { t.Fatal("cluster-wide informer did not sync") } if got, want := clusterWide.NamespaceObservation(clusterGVR, "any"), clusterWide.Observation(clusterGVR); !reflect.DeepEqual(got, want) || got.ReasonCode != "informer_synced" { t.Fatalf("a cluster-wide informer serves every namespace: %+v, want %+v", got, want) } } func TestServedEntriesFollowAClusterWideInformerReplacingNamespaceOnes(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) release := make(chan struct{}) defer close(release) dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) { if a.GetNamespace() == "" { <-release } return false, nil, nil }) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if err := d.startWatching(gvr, "alpha"); err != nil { t.Fatal(err) } deadline := time.Now().Add(3 * time.Second) for !d.IsNamespaceSynced(gvr, "alpha") && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if _, synced := d.servedEntries(gvr, "alpha"); !synced { t.Fatal("alpha's informer did not sync") } if _, synced := d.servedEntries(gvr, ""); synced { t.Fatal("namespace informers must not answer a cluster-wide read") } // The cluster-wide informer replaces alpha's; its initial list is held. if err := d.startWatching(gvr, ""); err != nil { t.Fatal(err) } if _, synced := d.servedEntries(gvr, "alpha"); synced { t.Fatal("after the replacement, alpha is served by the cluster-wide informer, which hasn't loaded") } } // A 401 rejects Radar's credentials, not one scope, so every probe stops on it // instead of narrowing the kind to the namespaces that answered, and each path // that records a failed probe says credentials were rejected, not access denied. func TestProbesStopOnRejectedCredentials(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} for name, rejected := range map[string]string{"cluster-wide probe": "", "a fallback namespace": "beta"} { newCache := func(t *testing.T, fallbacks ...string) *DynamicResourceCache { dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}) dyn.PrependReactor("list", "widgets", func(action k8stesting.Action) (bool, runtime.Object, error) { switch ns := action.GetNamespace(); { case ns == rejected: return true, nil, apierrors.NewUnauthorized("token rejected") case ns == "": return true, nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("cluster-wide list denied")) } return false, nil, nil }) if len(fallbacks) == 0 { fallbacks = []string{"alpha", "beta"} } cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn, NamespaceFallbacks: fallbacks}) if err != nil { t.Fatal(err) } t.Cleanup(cache.Stop) return cache } rejectedCredentials := func(t *testing.T, cache *DynamicResourceCache) { t.Helper() if got := cache.Observation(gvr); got.State != DynamicObservationDenied || got.ReasonCode != "credentials_rejected" { t.Fatalf("observation=%+v, want a credential rejection, not an access denial", got) } } t.Run(name, func(t *testing.T) { t.Run("probeScopes", func(t *testing.T) { scopes, _, err := newCache(t).probeScopes(gvr, "") if !apierrors.IsUnauthorized(err) || scopes != nil { t.Fatalf("scopes=%v err=%v, want the credential rejection", scopes, err) } }) t.Run("EnsureWatching", func(t *testing.T) { cache := newCache(t) if err := cache.EnsureWatching(gvr); !apierrors.IsUnauthorized(err) { t.Fatalf("EnsureWatching err=%v, want the credential rejection", err) } rejectedCredentials(t, cache) }) t.Run("WarmupParallel", func(t *testing.T) { cache := newCache(t) cache.WarmupParallel([]schema.GroupVersionResource{gvr}, time.Second) rejectedCredentials(t, cache) }) t.Run("probeCount", func(t *testing.T) { // The count probe samples the first fallback namespace that // answers, so the rejected one comes first here. cache := newCache(t, "beta", "alpha") count, err := cache.probeCount(gvr) if count != -1 || !apierrors.IsUnauthorized(err) { t.Fatalf("count=%d err=%v, want -1 with the credential rejection", count, err) } if !cache.retainAuthProbeFailure(gvr, "", true, err) { t.Fatal("the count probe's rejection was not recorded") } rejectedCredentials(t, cache) }) }) } } // Stop keeps the informers, which go on reporting synced; nothing that vouches // for an answer may do so once the cache has stopped. func TestStoppedCacheVouchesForNothing(t *testing.T) { widgets := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} gizmos := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "gizmos"} dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{widgets: "WidgetList", gizmos: "GizmoList"}) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if err := d.startWatching(widgets, "alpha"); err != nil { t.Fatal(err) } if err := d.startWatching(gizmos, ""); err != nil { t.Fatal(err) } deadline := time.Now().Add(3 * time.Second) for !(d.IsNamespaceSynced(widgets, "alpha") && d.IsClusterWideSynced(gizmos)) && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if _, synced := d.servedEntries(widgets, "alpha"); !synced { t.Fatal("alpha's informer did not sync") } if _, synced := d.servedEntries(gizmos, ""); !synced { t.Fatal("the cluster-wide informer did not sync") } d.Stop() if _, synced := d.servedEntries(widgets, "alpha"); synced { t.Error("a namespace informer of a stopped cache vouches for reads") } if _, synced := d.servedEntries(gizmos, ""); synced { t.Error("a cluster-wide informer of a stopped cache vouches for reads") } if d.IsNamespaceSynced(widgets, "alpha") || d.IsClusterWideSynced(gizmos) || d.IsSynced(gizmos) { t.Error("a stopped cache reports synced") } if _, ok := d.WatchedSince(gizmos); ok { t.Error("WatchedSince vouches for a stopped cache") } if _, err := d.Count(gizmos, nil); err == nil { t.Error("Count answers for a stopped cache") } if counts := d.CountWatched(nil); counts != nil { t.Errorf("CountWatched answers for a stopped cache: %v", counts) } } func newWidgetDynamicClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{gvr: "WidgetList"}, objects...) } func TestDynamicReadableObjectAnswersOnlyFromSyncedServingInformers(t *testing.T) { gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} dyn := newWidgetDynamicClient(&unstructured.Unstructured{Object: map[string]any{ "apiVersion": "example.io/v1", "kind": "Widget", "metadata": map[string]any{"name": "present", "namespace": "alpha"}, }}) release := make(chan struct{}) defer close(release) dyn.PrependReactor("list", "widgets", func(a k8stesting.Action) (bool, runtime.Object, error) { if a.GetNamespace() == "" { <-release } return false, nil, nil }) d, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dyn}) if err != nil { t.Fatal(err) } t.Cleanup(d.Stop) if _, found, ok := d.ReadableObject(gvr, "alpha", "present"); found || ok { t.Fatal("with no informer, a read must prove nothing") } if err := d.startWatching(gvr, "alpha"); err != nil { t.Fatal(err) } deadline := time.Now().Add(3 * time.Second) for !d.IsNamespaceSynced(gvr, "alpha") && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } obj, found, ok := d.ReadableObject(gvr, "alpha", "present") if !found || !ok || obj.GetName() != "present" { t.Fatalf("synced read: found=%v ok=%v", found, ok) } if _, found, ok := d.ReadableObject(gvr, "alpha", "missing"); found || !ok { t.Fatalf("a synced store's miss: found=%v ok=%v; want an established absence", found, ok) } if _, _, ok := d.ReadableObject(gvr, "beta", "present"); ok { t.Fatal("no informer serves beta, so the read proves nothing") } // A cluster-wide informer replaces alpha's; its initial list is held. if err := d.startWatching(gvr, ""); err != nil { t.Fatal(err) } if _, found, ok := d.ReadableObject(gvr, "alpha", "present"); found || ok { t.Fatalf("after the replacement: found=%v ok=%v; want the unloaded cluster-wide informer to prove nothing", found, ok) } }