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 "" }