Ctrl K
ComunidadGuías…

pkg/k8score/dynamic_cache_late_crd_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	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/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	fakediscovery "k8s.io/client-go/discovery/fake"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	fakeclientset "k8s.io/client-go/kubernetes/fake"
	k8stesting "k8s.io/client-go/testing"
)

// lockedDiscovery lets a test change the served resources while Radar
// refreshes discovery in the background.
type lockedDiscovery struct {
	*fakediscovery.FakeDiscovery
	mu sync.Mutex
}

func newLockedDiscovery(resources ...*metav1.APIResourceList) *lockedDiscovery {
	fake := fakeclientset.NewSimpleClientset().Discovery().(*fakediscovery.FakeDiscovery)
	fake.Resources = resources
	return &lockedDiscovery{FakeDiscovery: fake}
}

func (d *lockedDiscovery) serve(list *metav1.APIResourceList) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.Resources = append(d.Resources, list)
}

func (d *lockedDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.FakeDiscovery.ServerGroupsAndResources()
}

var crdResources = &metav1.APIResourceList{
	GroupVersion: "apiextensions.k8s.io/v1",
	APIResources: []metav1.APIResource{{Name: "customresourcedefinitions", Kind: "CustomResourceDefinition", Verbs: []string{"get", "list", "watch"}}},
}

var dbClusterResources = &metav1.APIResourceList{
	GroupVersion: "db.example.com/v1alpha1",
	APIResources: []metav1.APIResource{{Name: "dbclusters", Kind: "DBCluster", Namespaced: true, Verbs: []string{"get", "list", "watch"}}},
}

func crdObject(name, group, plural string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apiextensions.k8s.io/v1",
		"kind":       "CustomResourceDefinition",
		"metadata":   map[string]any{"name": name},
		"spec": map[string]any{
			"group": group,
			"names": map[string]any{"plural": plural},
		},
	}}
}

func waitUntilTrue(t *testing.T, what string, cond func() bool) {
	t.Helper()
	deadline := time.Now().Add(10 * time.Second)
	for !cond() {
		if time.Now().After(deadline) {
			t.Fatalf("timed out waiting for %s", what)
		}
		time.Sleep(50 * time.Millisecond)
	}
}

// A CRD installed after startup discovery gets the same decision as one that
// was there at startup: watched eagerly when it has few objects, so its
// changes reach the timeline before anyone lists the kind.
func TestCRDInstalledAfterDiscoveryIsWatched(t *testing.T) {
	old := lateCRDScanDelay
	lateCRDScanDelay = 50 * time.Millisecond
	t.Cleanup(func() { lateCRDScanDelay = old })

	widgets := schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"}
	clusters := schema.GroupVersionResource{Group: "db.example.com", Version: "v1alpha1", Resource: "dbclusters"}

	fakeDiscovery := newLockedDiscovery(crdResources, &metav1.APIResourceList{
		GroupVersion: "example.com/v1",
		APIResources: []metav1.APIResource{{Name: "widgets", Kind: "Widget", Namespaced: true, Verbs: []string{"get", "list", "watch"}}},
	})
	discovery, err := NewResourceDiscovery(fakeDiscovery, WithDiscoveryCacheTTL(time.Hour))
	if err != nil {
		t.Fatalf("NewResourceDiscovery: %v", err)
	}
	dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
		map[schema.GroupVersionResource]string{
			crdGVR:   "CustomResourceDefinitionList",
			widgets:  "WidgetList",
			clusters: "DBClusterList",
		},
		crdObject("widgets.example.com", "example.com", "widgets"),
	)
	var (
		changesMu sync.Mutex
		changes   []ResourceChange
	)
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{
		DynamicClient: dynamicClient,
		Discovery:     discovery,
		OnChange: func(c ResourceChange, _, _ any) {
			changesMu.Lock()
			changes = append(changes, c)
			changesMu.Unlock()
		},
	})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache: %v", err)
	}
	t.Cleanup(cache.Stop)

	cache.DiscoverAllCRDs()
	select {
	case <-cache.discoveryDone:
	case <-time.After(5 * time.Second):
		t.Fatal("DiscoverAllCRDs did not complete")
	}
	waitUntilTrue(t, "startup CRD watched", func() bool { return cache.hasCoveringInformer(widgets, "") })
	if cache.hasCoveringInformer(clusters, "") {
		t.Fatal("dbclusters watched before its CRD was installed")
	}

	// Install the CRD: the API server starts serving the kind, then the CRD
	// object appears.
	fakeDiscovery.serve(dbClusterResources)
	if _, err := dynamicClient.Resource(crdGVR).Create(context.Background(),
		crdObject("dbclusters.db.example.com", "db.example.com", "dbclusters"), metav1.CreateOptions{}); err != nil {
		t.Fatalf("create CRD: %v", err)
	}

	waitUntilTrue(t, "late-installed CRD watched", func() bool { return cache.hasCoveringInformer(clusters, "") })
	if !cache.WaitForSync(clusters, 5*time.Second) {
		t.Fatal("late-installed CRD informer did not sync")
	}

	// An edit to one of its objects reaches the timeline callback without
	// anyone having listed the kind.
	cr := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "db.example.com/v1alpha1", "kind": "DBCluster",
		"metadata": map[string]any{"name": "basic", "namespace": "default"},
		"spec":     map[string]any{"updateStrategy": "RollingUpdate"},
	}}
	created, err := dynamicClient.Resource(clusters).Namespace("default").Create(context.Background(), cr, metav1.CreateOptions{})
	if err != nil {
		t.Fatalf("create CR: %v", err)
	}
	if err := unstructured.SetNestedField(created.Object, "SomeStrategyForUpdate", "spec", "updateStrategy"); err != nil {
		t.Fatal(err)
	}
	if _, err := dynamicClient.Resource(clusters).Namespace("default").Update(context.Background(), created, metav1.UpdateOptions{}); err != nil {
		t.Fatalf("update CR: %v", err)
	}
	waitUntilTrue(t, "CR update recorded", func() bool {
		changesMu.Lock()
		defer changesMu.Unlock()
		for _, c := range changes {
			if c.Kind == "DBCluster" && c.Name == "basic" && c.Operation == OpUpdate {
				return true
			}
		}
		return false
	})
}

// A CRD event can arrive before discovery serves the new kind (the CRD isn't
// Established yet, or discovery failed). The scan is retried until it is.
func TestLateCRDScanRetriesUntilServed(t *testing.T) {
	old := lateCRDScanDelay
	lateCRDScanDelay = 20 * time.Millisecond
	t.Cleanup(func() { lateCRDScanDelay = old })

	clusters := schema.GroupVersionResource{Group: "db.example.com", Version: "v1alpha1", Resource: "dbclusters"}
	fakeDiscovery := newLockedDiscovery(crdResources)
	discovery, err := NewResourceDiscovery(fakeDiscovery, WithDiscoveryCacheTTL(time.Hour))
	if err != nil {
		t.Fatalf("NewResourceDiscovery: %v", err)
	}
	dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
		map[schema.GroupVersionResource]string{crdGVR: "CustomResourceDefinitionList", clusters: "DBClusterList"},
		crdObject("dbclusters.db.example.com", "db.example.com", "dbclusters"),
	)
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dynamicClient, Discovery: discovery})
	if err != nil {
		t.Fatalf("NewDynamicResourceCache: %v", err)
	}
	t.Cleanup(cache.Stop)

	cache.DiscoverAllCRDs()
	select {
	case <-cache.discoveryDone:
	case <-time.After(5 * time.Second):
		t.Fatal("DiscoverAllCRDs did not complete")
	}
	// The CRD object exists but discovery doesn't serve its kind yet.
	waitUntilTrue(t, "CRD pending", func() bool {
		cache.lateCRDMu.Lock()
		defer cache.lateCRDMu.Unlock()
		_, pending := cache.pendingCRDs["db.example.com/dbclusters"]
		return pending
	})
	time.Sleep(100 * time.Millisecond) // at least one scan that finds nothing
	if cache.hasCoveringInformer(clusters, "") {
		t.Fatal("watched before discovery served the kind")
	}

	fakeDiscovery.serve(dbClusterResources)
	waitUntilTrue(t, "CRD watched after a retry", func() bool { return cache.hasCoveringInformer(clusters, "") })
}

func TestLateCRDCountFailureIsRetried(t *testing.T) {
	old := lateCRDScanDelay
	lateCRDScanDelay = 20 * time.Millisecond
	t.Cleanup(func() { lateCRDScanDelay = old })

	clusters := schema.GroupVersionResource{Group: "db.example.com", Version: "v1alpha1", Resource: "dbclusters"}
	discovery, err := NewResourceDiscovery(newLockedDiscovery(crdResources, dbClusterResources), WithDiscoveryCacheTTL(time.Hour))
	if err != nil {
		t.Fatal(err)
	}
	dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
		map[schema.GroupVersionResource]string{clusters: "DBClusterList"})
	var attempts atomic.Int32
	dynamicClient.PrependReactor("list", "dbclusters", func(k8stesting.Action) (bool, runtime.Object, error) {
		if attempts.Add(1) == 1 {
			return true, nil, apierrors.NewNotFound(clusters.GroupResource(), "")
		}
		return false, nil, nil
	})
	cache, err := NewDynamicResourceCache(DynamicCacheConfig{DynamicClient: dynamicClient, Discovery: discovery})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(cache.Stop)

	cache.onCRDEvent(crdObject("dbclusters.db.example.com", "db.example.com", "dbclusters"))
	waitUntilTrue(t, "CRD watched after transient count failure", func() bool {
		return cache.hasCoveringInformer(clusters, "")
	})
	if !cache.WaitForSync(clusters, 5*time.Second) {
		t.Fatal("late CRD informer did not sync")
	}
	waitUntilTrue(t, "retry scan complete", func() bool {
		cache.lateCRDMu.Lock()
		defer cache.lateCRDMu.Unlock()
		_, seen := cache.seenCRDs["db.example.com/dbclusters"]
		return seen && len(cache.pendingCRDs) == 0 && !cache.lateCRDScanning
	})
}

func TestLateCRDScanOnlyHandlesNewCRDs(t *testing.T) {
	cache := &DynamicResourceCache{seenCRDs: map[string]struct{}{}, pendingCRDs: map[string]struct{}{}}

	if fresh := cache.markCRDsSeen([]string{"a.io/as"}); len(fresh) != 1 {
		t.Fatalf("first sight of a: got %v", fresh)
	}
	cache.pendingCRDs["b.io/bs"] = struct{}{}
	fresh := cache.markCRDsSeen([]string{"a.io/as", "b.io/bs"})
	if _, ok := fresh["b.io/bs"]; !ok || len(fresh) != 1 {
		t.Fatalf("expected only b to be new, got %v", fresh)
	}
	if len(cache.pendingCRDs) != 0 {
		t.Fatalf("seen CRD still pending: %v", cache.pendingCRDs)
	}

	// After the CRD is deleted, reinstalling it is handled again.
	cache.forgetCRD(crdObject("as.a.io", "a.io", "as"))
	if fresh := cache.markCRDsSeen([]string{"a.io/as"}); len(fresh) != 1 {
		t.Fatalf("reinstalled a should be new again, got %v", fresh)
	}
}

// Updates to CRDs that already had a watch decision must not refresh
// discovery: some controllers rewrite CRDs regularly.
func TestKnownCRDEventsDoNotScheduleScan(t *testing.T) {
	old := lateCRDScanDelay
	lateCRDScanDelay = time.Hour
	t.Cleanup(func() { lateCRDScanDelay = old })

	cache := &DynamicResourceCache{seenCRDs: map[string]struct{}{"a.io/as": {}}, pendingCRDs: map[string]struct{}{}}
	cache.onCRDEvent(crdObject("as.a.io", "a.io", "as"))
	if cache.lateCRDTimer != nil {
		t.Fatal("event for a known CRD scheduled a scan")
	}
	cache.lateCRDRetries = lateCRDMaxRetries
	cache.onCRDEvent(crdObject("bs.b.io", "b.io", "bs"))
	if cache.lateCRDTimer == nil {
		t.Fatal("event for a new CRD did not schedule a scan")
	}
	if cache.lateCRDRetries != 0 {
		t.Fatal("a newly pending CRD did not get the full retry budget")
	}
	cache.lateCRDTimer.Stop()
}