Ctrl K
ComunidadGuías…

pkg/k8score/discovery.go

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

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"fmt"
	"log"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/discovery"
)

// APIResource represents a discovered API resource type.
type APIResource struct {
	Group      string   `json:"group"`
	Version    string   `json:"version"`
	Kind       string   `json:"kind"`
	Name       string   `json:"name"` // Plural name (e.g., "deployments")
	Namespaced bool     `json:"namespaced"`
	IsCRD      bool     `json:"isCrd"`
	Verbs      []string `json:"verbs"`
}

// DiscoveryStats holds read-only stats about API discovery state.
type DiscoveryStats struct {
	TotalResources int
	CRDCount       int
	LastRefresh    time.Time
}

// ResourceDiscovery manages discovery and caching of API resources.
// It is safe for concurrent use.
type ResourceDiscovery struct {
	client      discovery.DiscoveryInterface
	resources   []APIResource
	resourceMap map[string]APIResource // keyed by lowercase kind
	gvrMap      map[string]schema.GroupVersionResource
	lastRefresh time.Time
	partial     bool
	failedGroup map[string]bool
	// refreshFailed: the last refresh failed without usable resource data, so
	// the ones held, if any, are from an earlier refresh.
	refreshFailed bool
	cacheTTL      time.Duration
	refreshMu     sync.Mutex
	mu            sync.RWMutex

	// complete records that the discovery request that set resources returned
	// no error; see DiscoveryCatalog.
	complete bool
}

// DiscoveryOption is a functional option for NewResourceDiscovery.
type DiscoveryOption func(*ResourceDiscovery)

// WithDiscoveryCacheTTL overrides the default 5-minute refresh interval.
func WithDiscoveryCacheTTL(d time.Duration) DiscoveryOption {
	return func(rd *ResourceDiscovery) {
		rd.cacheTTL = d
	}
}

// coreAPIGroups are groups that ship with Kubernetes core.
var coreAPIGroups = map[string]bool{
	"":                             true,
	"apps":                         true,
	"batch":                        true,
	"autoscaling":                  true,
	"networking.k8s.io":            true,
	"policy":                       true,
	"rbac.authorization.k8s.io":    true,
	"storage.k8s.io":               true,
	"admissionregistration.k8s.io": true,
	"apiextensions.k8s.io":         true,
	"certificates.k8s.io":          true,
	"coordination.k8s.io":          true,
	"discovery.k8s.io":             true,
	"events.k8s.io":                true,
	"flowcontrol.apiserver.k8s.io": true,
	"node.k8s.io":                  true,
	"scheduling.k8s.io":            true,
}

var dynamicallyWatchedBuiltInAPIGroups = map[string]bool{
	"apiregistration.k8s.io": true,
	"authentication.k8s.io":  true,
	"authorization.k8s.io":   true,
	"resource.k8s.io":        true,
}

// IsBuiltInAPIGroup reports whether group is shipped by Kubernetes rather than
// introduced by a CRD. Some built-in groups remain outside coreAPIGroups so the
// dynamic cache can observe them.
func IsBuiltInAPIGroup(group string) bool {
	return coreAPIGroups[group] || dynamicallyWatchedBuiltInAPIGroups[group]
}

// versionStability returns a score for API version stability.
// Higher is more stable: stable (3) > beta (2) > alpha (1).
func versionStability(version string) int {
	if strings.Contains(version, "alpha") {
		return 1
	}
	if strings.Contains(version, "beta") {
		return 2
	}
	return 3 // v1, v2, etc.
}

// versionRegex parses Kubernetes API versions like "v1", "v2beta1", "v1alpha2".
var versionRegex = regexp.MustCompile(`^v(\d+)(?:(alpha|beta)(\d+))?$`)

// parseVersion extracts the numeric components of a Kubernetes API version.
func parseVersion(version string) (major, qualifierNum int) {
	m := versionRegex.FindStringSubmatch(version)
	if m == nil {
		return 0, 0
	}
	major, _ = strconv.Atoi(m[1])
	if m[3] != "" {
		qualifierNum, _ = strconv.Atoi(m[3])
	}
	return
}

// IsMoreStableVersion returns true if newVersion is more stable than oldVersion.
// Compares stability tier first (stable > beta > alpha), then numeric version
// within the same tier (v1beta3 > v1beta2, v2 > v1).
func IsMoreStableVersion(newVersion, oldVersion string) bool {
	newStab := versionStability(newVersion)
	oldStab := versionStability(oldVersion)
	if newStab != oldStab {
		return newStab > oldStab
	}
	newMajor, newQual := parseVersion(newVersion)
	oldMajor, oldQual := parseVersion(oldVersion)
	if newMajor != oldMajor {
		return newMajor > oldMajor
	}
	return newQual > oldQual
}

// NewResourceDiscovery creates a ResourceDiscovery backed by the given client.
// It performs an initial refresh; returns an error only if the client is nil.
// isNilDiscoveryClient reports a client that cannot be called, including the
// case a plain `client == nil` misses: a nil *discovery.DiscoveryClient stored
// in this interface makes a non-nil interface value, so the guard passes and
// the first method call dereferences nil. Callers reach that by handing over
// the result of an accessor that returns a concrete pointer before the client
// is built.
func isNilDiscoveryClient(client discovery.DiscoveryInterface) bool {
	if client == nil {
		return true
	}
	v := reflect.ValueOf(client)
	switch v.Kind() {
	case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func:
		return v.IsNil()
	}
	return false
}

func NewResourceDiscovery(client discovery.DiscoveryInterface, opts ...DiscoveryOption) (*ResourceDiscovery, error) {
	if isNilDiscoveryClient(client) {
		return nil, fmt.Errorf("discovery client must not be nil")
	}

	rd := &ResourceDiscovery{
		client:      client,
		resourceMap: make(map[string]APIResource),
		gvrMap:      make(map[string]schema.GroupVersionResource),
		cacheTTL:    5 * time.Minute,
	}
	for _, opt := range opts {
		opt(rd)
	}

	if err := rd.Refresh(); err != nil {
		// Log but don't fail — partial results are OK
		log.Printf("Warning: initial API resource discovery returned partial results: %v", err)
	}

	return rd, nil
}

// Refresh fetches all API resources from the cluster.
func (d *ResourceDiscovery) Refresh() error {
	if d == nil {
		return fmt.Errorf("discovery not initialized")
	}
	d.refreshMu.Lock()
	defer d.refreshMu.Unlock()
	return d.refresh()
}

func (d *ResourceDiscovery) refresh() error {
	if d == nil || d.client == nil {
		return fmt.Errorf("discovery not initialized")
	}

	start := time.Now()
	_, apiResourceLists, err := d.client.ServerGroupsAndResources()
	if err != nil {
		log.Printf("Warning: partial error discovering API resources: %v", err)
	}
	hasResourceData := false
	for _, apiList := range apiResourceLists {
		if apiList != nil && len(apiList.APIResources) > 0 {
			hasResourceData = true
			break
		}
	}
	if err != nil && !hasResourceData {
		d.mu.Lock()
		d.lastRefresh = time.Now()
		d.refreshFailed = true
		d.mu.Unlock()
		return err
	}
	failedGroups := make(map[string]bool)
	if groups, ok := discovery.GroupDiscoveryFailedErrorGroups(err); ok {
		for gv := range groups {
			failedGroups[gv.Group] = true
		}
	}
	partial := discovery.IsGroupDiscoveryFailedError(err) || len(failedGroups) > 0
	log.Printf("API resource discovery took %v", time.Since(start))

	d.mu.Lock()
	defer d.mu.Unlock()

	d.resources = nil
	d.resourceMap = make(map[string]APIResource)
	d.gvrMap = make(map[string]schema.GroupVersionResource)
	for _, apiList := range apiResourceLists {
		if apiList == nil {
			continue
		}

		gv, err := schema.ParseGroupVersion(apiList.GroupVersion)
		if err != nil {
			continue
		}

		for _, apiRes := range apiList.APIResources {
			if strings.Contains(apiRes.Name, "/") {
				continue
			}

			isCRD := !coreAPIGroups[gv.Group]

			resource := APIResource{
				Group:      gv.Group,
				Version:    gv.Version,
				Kind:       apiRes.Kind,
				Name:       apiRes.Name,
				Namespaced: apiRes.Namespaced,
				IsCRD:      isCRD,
				Verbs:      apiRes.Verbs,
			}

			d.addResourceLocked(resource)
		}
	}

	d.lastRefresh = time.Now()
	d.partial = partial
	d.failedGroup = failedGroups
	d.refreshFailed = false
	d.complete = err == nil
	log.Printf("Discovered %d API resources (%d unique kinds)", len(d.resources), len(d.resourceMap)/2)

	return nil
}

// HasPartialDiscovery reports whether the last refresh missed one or more
// group/versions while still returning partial API resource data.
func (d *ResourceDiscovery) HasPartialDiscovery() bool {
	if d == nil {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.partial
}

// LastRefreshFailed reports whether the last refresh failed without usable
// resource data. The resources held, if any, are then from an earlier
// refresh, so an API installed since is missing from them. A partial
// refresh does not set it; see HasPartialDiscovery.
func (d *ResourceDiscovery) LastRefreshFailed() bool {
	if d == nil {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.refreshFailed
}

func (d *ResourceDiscovery) GroupsForKind(kind string) []string {
	if d == nil {
		return nil
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	var groups []string
	for _, resource := range d.resources {
		if resource.Kind == kind {
			groups = append(groups, resource.Group)
		}
	}
	return groups
}

func (d *ResourceDiscovery) Complete() bool {
	if d == nil {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.complete && !d.refreshFailed
}

// GroupHadPartialDiscovery reports whether a group was specifically missing
// from the last partial discovery response.
func (d *ResourceDiscovery) GroupHadPartialDiscovery(group string) bool {
	if d == nil {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.failedGroup[group]
}

// AddAPIResource registers a resource that was proven accessible without
// server discovery returning it, such as under restricted discovery RBAC.
func (d *ResourceDiscovery) AddAPIResource(resource APIResource) {
	if d == nil {
		return
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	d.addResourceLocked(resource)
}

func (d *ResourceDiscovery) addResourceLocked(resource APIResource) {
	for i, existing := range d.resources {
		if existing.Group == resource.Group && existing.Version == resource.Version && existing.Name == resource.Name {
			d.resources[i] = resource
			d.indexResourceLocked(resource)
			return
		}
	}
	d.resources = append(d.resources, resource)
	d.indexResourceLocked(resource)
}

func (d *ResourceDiscovery) indexResourceLocked(resource APIResource) {
	gvr := schema.GroupVersionResource{
		Group:    resource.Group,
		Version:  resource.Version,
		Resource: resource.Name,
	}

	// Store in map by lowercase kind for lookup. Precedence, in order:
	//   1. non-CRD over CRD,
	//   2. within the same group + CRD-ness, a more stable version,
	//   3. among same CRD-ness, a list/watch-capable resource over one that
	//      lacks those verbs (e.g. a real CRD over an aggregated APIService),
	//   4. a deterministic sorted-group tie-break so the winner does not depend
	//      on discovery order when two groups are otherwise indistinguishable.
	// Rules 3-4 resolve bare-kind collisions across groups deterministically: for
	// localqueues in kueue.x-k8s.io vs visibility.kueue.x-k8s.io, the list/watch-
	// capable real CRD wins over the aggregated APIService that lacks those verbs,
	// and when candidates are otherwise indistinguishable the lowest group name
	// wins so the result does not depend on discovery order.
	kindKey := strings.ToLower(resource.Kind)
	if existing, ok := d.resourceMap[kindKey]; !ok || preferResource(resource, existing) {
		d.resourceMap[kindKey] = resource
		d.gvrMap[kindKey] = gvr
	}

	nameKey := strings.ToLower(resource.Name)
	if existing, ok := d.resourceMap[nameKey]; !ok || preferResource(resource, existing) {
		d.resourceMap[nameKey] = resource
		d.gvrMap[nameKey] = gvr
	}
}

// preferResource reports whether resource should replace existing as the
// bare-kind/name winner in the lookup maps. It encodes the precedence documented
// in indexResourceLocked.
func preferResource(resource, existing APIResource) bool {
	// Non-CRD over CRD.
	if !resource.IsCRD && existing.IsCRD {
		return true
	}
	if resource.IsCRD != existing.IsCRD {
		return false
	}

	// Same CRD-ness beyond this point.
	// More stable version within the same group.
	if existing.Group == resource.Group {
		return IsMoreStableVersion(resource.Version, existing.Version)
	}

	// Different groups: prefer a list/watch-capable resource so a browsable
	// resource wins over a list/watch-less aggregated API.
	resourceLW := hasListWatch(resource.Verbs)
	existingLW := hasListWatch(existing.Verbs)
	if resourceLW != existingLW {
		return resourceLW
	}

	// Otherwise break the tie deterministically by sorted group name so the
	// result is stable regardless of discovery order.
	return resource.Group < existing.Group
}

// hasListWatch reports whether verbs include both list and watch.
func hasListWatch(verbs []string) bool {
	hasList := false
	hasWatch := false
	for _, verb := range verbs {
		if verb == "list" {
			hasList = true
		}
		if verb == "watch" {
			hasWatch = true
		}
	}
	return hasList && hasWatch
}

// Stats returns lightweight stats without triggering a refresh.
func (d *ResourceDiscovery) Stats() DiscoveryStats {
	if d == nil {
		return DiscoveryStats{}
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	crdCount := 0
	for _, res := range d.resources {
		if res.IsCRD {
			crdCount++
		}
	}

	return DiscoveryStats{
		TotalResources: len(d.resources),
		CRDCount:       crdCount,
		LastRefresh:    d.lastRefresh,
	}
}

// RefreshIfStale refreshes discovery when its cache TTL has elapsed. Concurrent
// callers share one refresh.
func (d *ResourceDiscovery) RefreshIfStale() error {
	if d == nil {
		return fmt.Errorf("resource discovery not initialized")
	}

	d.mu.RLock()
	needsRefresh := time.Since(d.lastRefresh) > d.cacheTTL
	d.mu.RUnlock()
	if !needsRefresh {
		return nil
	}

	d.refreshMu.Lock()
	defer d.refreshMu.Unlock()

	d.mu.RLock()
	needsRefresh = time.Since(d.lastRefresh) > d.cacheTTL
	d.mu.RUnlock()
	if !needsRefresh {
		return nil
	}
	return d.refresh()
}

// GetAPIResources returns all discovered API resources, deduplicating by
// name+group and keeping the most stable version.
func (d *ResourceDiscovery) GetAPIResources() ([]APIResource, error) {
	if d == nil {
		return nil, fmt.Errorf("resource discovery not initialized")
	}

	if err := d.RefreshIfStale(); err != nil {
		log.Printf("Warning: failed to refresh API resources: %v", err)
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	type entry struct {
		index   int
		version string
	}
	seen := make(map[string]entry, len(d.resources))
	result := make([]APIResource, 0, len(d.resources))

	for _, res := range d.resources {
		key := res.Name + "/" + res.Group
		if existing, ok := seen[key]; !ok {
			seen[key] = entry{index: len(result), version: res.Version}
			result = append(result, res)
		} else if IsMoreStableVersion(res.Version, existing.version) {
			result[existing.index] = res
			seen[key] = entry{index: existing.index, version: res.Version}
		}
	}

	return result, nil
}

// GetGVR returns the GroupVersionResource for a given kind or plural name.
// NOTE: When multiple resources share the same Kind across different API groups,
// this resolves the collision deterministically: a list/watch-capable resource
// wins over one lacking those verbs, then the lowest group name breaks ties, so
// the result does not depend on discovery order. Still use GetGVRWithGroup when a
// caller needs a specific API group.
func (d *ResourceDiscovery) GetGVR(kindOrName string) (schema.GroupVersionResource, bool) {
	if d == nil {
		return schema.GroupVersionResource{}, false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	gvr, ok := d.gvrMap[strings.ToLower(kindOrName)]
	return gvr, ok
}

// GetGVRWithGroup returns the GroupVersionResource for a kind with a specific API group.
func (d *ResourceDiscovery) GetGVRWithGroup(kindOrName string, group string) (schema.GroupVersionResource, bool) {
	if d == nil {
		return schema.GroupVersionResource{}, false
	}

	if group == "" {
		return d.GetGVR(kindOrName)
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	kindLower := strings.ToLower(kindOrName)
	var best *APIResource
	for i := range d.resources {
		res := &d.resources[i]
		if (strings.ToLower(res.Kind) == kindLower || strings.ToLower(res.Name) == kindLower) && res.Group == group {
			// Multiple served versions can coexist (e.g. Gateway v1beta1 + v1).
			// Informers are registered against the most stable version, so a
			// first-match return would hand back a version no informer watches.
			if best == nil || IsMoreStableVersion(res.Version, best.Version) {
				best = res
			}
		}
	}
	if best == nil {
		return schema.GroupVersionResource{}, false
	}
	return schema.GroupVersionResource{
		Group:    best.Group,
		Version:  best.Version,
		Resource: best.Name,
	}, true
}

// GetResource returns the APIResource for a given kind or plural name.
func (d *ResourceDiscovery) GetResource(kindOrName string) (APIResource, bool) {
	if d == nil {
		return APIResource{}, false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	res, ok := d.resourceMap[strings.ToLower(kindOrName)]
	return res, ok
}

// GetResourceWithGroup returns the APIResource for a kind in a specific
// API group. Mirrors GetGVRWithGroup, including its choice of the most stable
// served version, but yields the full resource (incl. Namespaced) rather than
// just the GVR. Empty group falls back to the kind-keyed lookup (with the same
// caveat as GetGVR).
//
// Used for authorization decisions where the caller has both kind and
// group from a request and needs to know the resource's scope before
// running a SubjectAccessReview.
func (d *ResourceDiscovery) GetResourceWithGroup(kindOrName, group string) (APIResource, bool) {
	if d == nil {
		return APIResource{}, false
	}

	if group == "" {
		return d.GetResource(kindOrName)
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	// Server discovery lists a group's versions in an order that varies from
	// process to process, so returning the first match would hand callers a
	// deprecated version at random, and the GVR they build from it would name
	// a version no informer is registered against.
	kindLower := strings.ToLower(kindOrName)
	var best *APIResource
	for i := range d.resources {
		res := &d.resources[i]
		if (strings.ToLower(res.Kind) == kindLower || strings.ToLower(res.Name) == kindLower) && res.Group == group {
			if best == nil || IsMoreStableVersion(res.Version, best.Version) {
				best = res
			}
		}
	}
	if best == nil {
		return APIResource{}, false
	}
	return *best, true
}

// IsKnownResource checks if a kind or plural name is a known resource.
func (d *ResourceDiscovery) IsKnownResource(kindOrName string) bool {
	_, ok := d.GetResource(kindOrName)
	return ok
}

// IsCRD checks if a kind or plural name is a CRD (not a core resource).
func (d *ResourceDiscovery) IsCRD(kindOrName string) bool {
	res, ok := d.GetResource(kindOrName)
	return ok && res.IsCRD
}

// IsCRDGVR reports whether the exact discovered resource is a CRD.
func (d *ResourceDiscovery) IsCRDGVR(gvr schema.GroupVersionResource) bool {
	if d == nil {
		return false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	for _, res := range d.resources {
		if res.Group == gvr.Group && res.Version == gvr.Version && res.Name == gvr.Resource {
			return res.IsCRD
		}
	}
	return false
}

// NamespacedGVR reports the scope of the exact discovered group, version and
// resource; known is false when discovery doesn't serve that version. Scope is
// read per version because an aggregated API registers each version on its own,
// so one resource name can be namespaced at one version and not at another.
func (d *ResourceDiscovery) NamespacedGVR(gvr schema.GroupVersionResource) (namespaced, known bool) {
	if d == nil {
		return false, false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	for _, res := range d.resources {
		if res.Group == gvr.Group && res.Version == gvr.Version && res.Name == gvr.Resource {
			return res.Namespaced, true
		}
	}
	return false, false
}

// SupportsWatch checks if a resource supports list and watch verbs.
func (d *ResourceDiscovery) SupportsWatch(kindOrName string) bool {
	res, ok := d.GetResource(kindOrName)
	if !ok {
		return false
	}
	return hasListWatch(res.Verbs)
}

// SupportsWatchGVR checks if a GVR supports list and watch verbs.
func (d *ResourceDiscovery) SupportsWatchGVR(gvr schema.GroupVersionResource) bool {
	if d == nil {
		return false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	for _, res := range d.resources {
		if res.Group != gvr.Group || res.Version != gvr.Version || res.Name != gvr.Resource {
			continue
		}
		return hasListWatch(res.Verbs)
	}
	return false
}

// HasKindInGroup reports whether a specific Kind exists within an API
// group. Use this when you depend on a specific CRD being registered.
func (d *ResourceDiscovery) HasKindInGroup(kind, group string) bool {
	if d == nil {
		return false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	kindLower := strings.ToLower(kind)
	for _, res := range d.resources {
		if res.Group == group && strings.ToLower(res.Kind) == kindLower {
			return true
		}
	}
	return false
}

// HasGroup reports whether any resource is registered under the given API
// group. Use it when the group itself is the signal — i.e. the group is
// owned by exactly one product, so any kind in it proves that product is
// installed. When a specific CRD must exist, use HasKindInGroup instead.
func (d *ResourceDiscovery) HasGroup(group string) bool {
	if d == nil {
		return false
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	for _, res := range d.resources {
		if res.Group == group {
			return true
		}
	}
	return false
}

// KyvernoLegacyPolicyGroup is the original Kyverno API group, home of the
// Policy/ClusterPolicy types deprecated in Kyverno 1.18 with removal
// planned for 1.20.
const KyvernoLegacyPolicyGroup = "kyverno.io"

// KyvernoModernPolicyGroup is the CEL-based policy family Kyverno
// stabilized in 1.17/1.18 (ValidatingPolicy, ImageValidatingPolicy,
// MutatingPolicy, ...). It is the family that survives the 1.20 removal.
const KyvernoModernPolicyGroup = "policies.kyverno.io"

// IsKyvernoInstalled reports whether Kyverno's CRDs are present on the
// cluster. Both API families count:
//
//   - kyverno.io — the legacy Policy/ClusterPolicy family, deprecated in
//     1.18 with removal planned for 1.20.
//   - policies.kyverno.io — the modern CEL family (ValidatingPolicy et al.)
//     that replaces it.
//
// Detecting only the legacy family would report not-installed on a
// modern-only cluster and silently drop the entire PolicyReport index —
// the reports would still exist, Radar would just stop indexing them.
// Whole-group presence is the right signal for the modern family because
// policies.kyverno.io is owned exclusively by Kyverno, so no single kind
// has to be nominated as the sentinel.
//
// Kyverno's own policy CRDs are the signal rather than the PolicyReport
// CRDs (wgpolicyk8s.io / openreports.io) because those are emitted by
// several engines (Trivy, Falco adapters, ...) and so do not by
// themselves imply Kyverno is the source.
//
// The signal drives conditional eager warmup of PolicyReport informers:
// clusters without Kyverno keep the reports in the deferred-fetch tier
// and pay no extra memory or watch budget.
func (d *ResourceDiscovery) IsKyvernoInstalled() bool {
	if d == nil {
		return false
	}
	return d.HasKindInGroup("Policy", KyvernoLegacyPolicyGroup) ||
		d.HasKindInGroup("ClusterPolicy", KyvernoLegacyPolicyGroup) ||
		d.HasGroup(KyvernoModernPolicyGroup)
}

// GetKindForGVR returns the Kind name for a given GVR
// e.g., for GVR{Resource: "rollouts"}, returns "Rollout".
func (d *ResourceDiscovery) GetKindForGVR(gvr schema.GroupVersionResource) string {
	if d == nil {
		return ""
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	for _, res := range d.resources {
		if res.Group == gvr.Group && res.Version == gvr.Version && res.Name == gvr.Resource {
			return res.Kind
		}
	}
	return ""
}