Ctrl K
ComunidadGuías…

pkg/resourceapi/coverage.go

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

Archivo original ↗Preguntar a KiBot
// Package resourceapi defines the coverage metadata Radar's resource reads
// share across REST, MCP and search: what an answer is about, what Radar read
// of it, and why anything in it is missing. Coverage qualifies a read of one
// kind, SearchCoverage a search across kinds; both report gaps with the same
// Gap and narrowing with the same Narrowing.
package resourceapi

import (
	"cmp"
	"encoding/json"
	"fmt"
	"slices"
	"strings"

	"github.com/skyhook-io/radar/pkg/readreason"
)

type CoverageStatus string

const (
	// CoverageComplete: Radar read all of the answer's scope.
	CoverageComplete CoverageStatus = "complete"
	// CoveragePartial: at least one gap; rows prove nothing about the gaps.
	CoveragePartial CoverageStatus = "partial"
)

// The sets below list their members only when there are at most a
// response's cap of them: a REST response is rendered by Radar's UI, an MCP
// response is read by a model. Above the cap a set carries only its count.
const (
	RESTSetLimit = 64
	MCPSetLimit  = 16
)

type ScopeType string

const (
	ScopeCluster       ScopeType = "cluster"
	ScopeAllNamespaces ScopeType = "all_namespaces"
	ScopeNamespaces    ScopeType = "namespaces"
)

// Narrowing says why an implicitly broad request covers less than everything.
// Narrowing applies per dimension: namespaces the request names are never
// narrowed, nor are kinds a search names, and what of them Radar could not
// read is a gap. The values are a separate enum from
// readreason.Action even where the spelling matches: narrowedBy user_access
// says the answer covers only what the user may read, the action user_access
// that the user's access has to change.
type Narrowing string

const (
	// NarrowedByUserAccess: the kinds or namespaces the caller may read.
	NarrowedByUserAccess Narrowing = "user_access"
	// NarrowedByNamespacePin: Radar's --namespace-scope pin.
	NarrowedByNamespacePin Narrowing = "namespace_pin"
	// NarrowedByNamespaceCollection: the namespaces Radar collects a kind in,
	// when its identity can't list the kind cluster-wide. Whoever runs Radar
	// widens it.
	NarrowedByNamespaceCollection Narrowing = "namespace_collection"
	// NarrowedByKindCollection: the kinds Radar collects. A search that names
	// no kind skips the kinds Radar isn't watching without listing each one;
	// naming a kind Radar can watch but hasn't started watching searches it.
	NarrowedByKindCollection Narrowing = "kind_collection"
)

// Extent says how much of the answer's scope a gap reaches along one axis
// when the gap can't list it.
type Extent string

const (
	// ExtentAll: every member of the scope along that axis.
	ExtentAll Extent = "all"
	// ExtentUnknown: some members, and Radar can't say which.
	ExtentUnknown Extent = "unknown"
)

// NameSet is a set of names. Either it is listed: Count says exactly how many
// members there are, and Names, when present, is all of them, sorted (absent
// when there are more than the response can carry, at any size; a reader
// learns membership from Names or not at all). Or Extent says the gap reaches
// all of the scope, or an unknown part of it, and carries no count.
type NameSet struct {
	Count  int      `json:"count"`
	Names  []string `json:"names,omitzero"`
	Extent Extent   `json:"extent,omitempty"`
}

// AllNames is a gap's namespaces when it reaches every namespace in scope.
func AllNames() *NameSet { return &NameSet{Extent: ExtentAll} }

// UnknownNames is a gap's namespaces when it reaches some, Radar can't say
// which.
func UnknownNames() *NameSet { return &NameSet{Extent: ExtentUnknown} }

// MarshalJSON writes an extent set as {"extent": …} alone: it has no count.
func (s NameSet) MarshalJSON() ([]byte, error) {
	if s.Extent != "" {
		return json.Marshal(struct {
			Extent Extent `json:"extent"`
		}{s.Extent})
	}
	type wire NameSet
	return json.Marshal(wire(s))
}

// Names builds a NameSet from names, which must already be safe to show the
// caller. It does not modify names.
func Names(names []string, limit int) *NameSet {
	names = slices.Clone(names)
	slices.Sort(names)
	names = slices.Compact(names)
	return &NameSet{Count: len(names), Names: listed(names, len(names), limit)}
}

// Capped is the set as a response with a smaller cap would carry it.
func (s *NameSet) Capped(limit int) *NameSet {
	if s == nil || s.Extent != "" {
		return s
	}
	return &NameSet{Count: s.Count, Names: listed(s.Names, s.Count, limit)}
}

// KindRef names a kind. An omitted group is the core group.
type KindRef struct {
	Kind  string `json:"kind"`
	Group string `json:"group,omitempty"`
}

// KindSet is a NameSet of kinds.
type KindSet struct {
	Count  int       `json:"count"`
	Names  []KindRef `json:"names,omitzero"`
	Extent Extent    `json:"extent,omitempty"`
}

// AllKinds is a gap's kinds when it reaches every kind in scope.
func AllKinds() *KindSet { return &KindSet{Extent: ExtentAll} }

// UnknownKinds is a gap's kinds when it reaches an unknown subset of the
// kinds in scope.
func UnknownKinds() *KindSet { return &KindSet{Extent: ExtentUnknown} }

// MarshalJSON writes an extent set as {"extent": …} alone, as NameSet does.
func (s KindSet) MarshalJSON() ([]byte, error) {
	if s.Extent != "" {
		return json.Marshal(struct {
			Extent Extent `json:"extent"`
		}{s.Extent})
	}
	type wire KindSet
	return json.Marshal(wire(s))
}

// Kinds builds a KindSet from kinds, as Names does from names: sorted by kind
// then group, without duplicates, listed only up to limit.
func Kinds(kinds []KindRef, limit int) *KindSet {
	kinds = slices.Clone(kinds)
	slices.SortFunc(kinds, func(a, b KindRef) int {
		return cmp.Or(strings.Compare(a.Kind, b.Kind), strings.Compare(a.Group, b.Group))
	})
	kinds = slices.Compact(kinds)
	return &KindSet{Count: len(kinds), Names: listed(kinds, len(kinds), limit)}
}

// Capped is the set as a response with a smaller cap would carry it.
func (s *KindSet) Capped(limit int) *KindSet {
	if s == nil || s.Extent != "" {
		return s
	}
	return &KindSet{Count: s.Count, Names: listed(s.Names, s.Count, limit)}
}

// ObjectRef names one object. An omitted group is the core group; an omitted
// namespace is a cluster-scoped object.
type ObjectRef struct {
	Kind      string `json:"kind"`
	Group     string `json:"group,omitempty"`
	Namespace string `json:"namespace,omitempty"`
	Name      string `json:"name"`
}

// ObjectSet is a listed set of objects, as NameSet without the extent form.
type ObjectSet struct {
	Count int         `json:"count"`
	Names []ObjectRef `json:"names,omitzero"`
}

// Objects builds an ObjectSet of count members (at least as many as known
// holds). known holds the ones the caller collected, which may be fewer; the
// set lists them, sorted, only when they are all of it.
func Objects(known []ObjectRef, count, limit int) *ObjectSet {
	known = slices.Clone(known)
	slices.SortFunc(known, func(a, b ObjectRef) int {
		return cmp.Or(strings.Compare(a.Namespace, b.Namespace), strings.Compare(a.Kind, b.Kind),
			strings.Compare(a.Group, b.Group), strings.Compare(a.Name, b.Name))
	})
	known = slices.Compact(known)
	count = max(count, len(known))
	return &ObjectSet{Count: count, Names: listed(known, count, limit)}
}

// Capped is the set as a response with a smaller cap would carry it.
func (s *ObjectSet) Capped(limit int) *ObjectSet {
	if s == nil {
		return nil
	}
	return &ObjectSet{Count: s.Count, Names: listed(s.Names, s.Count, limit)}
}

// listed is items as a set of count members lists them: all of them, or
// nothing when they are not all known or there are more than limit.
func listed[T any](items []T, count, limit int) []T {
	if len(items) != count || count > max(0, limit) {
		return nil
	}
	return append([]T{}, items...)
}

// Scope is what an answer is about. ScopeCluster (a cluster-scoped kind) and
// ScopeAllNamespaces carry no Namespaces; ScopeNamespaces always does. On a
// successful answer Scope and Read each name at least one namespace when they
// carry a set. An error's Scope may be an empty set: the user's access
// reaches no namespace.
type Scope struct {
	Type       ScopeType `json:"type"`
	Namespaces *NameSet  `json:"namespaces,omitempty"`
}

// Gap is one reason the answer isn't complete, and where it applies. It
// covers every combination of its axes: kinds × namespaces, or the objects
// named. An axis that applies is always present, listed or as an Extent; an
// absent axis doesn't apply to the gap. Which axes apply depends on the
// surface: a list of a namespaced kind gives every gap its namespaces, a list
// of a cluster-scoped kind none, and a search kinds or namespaces. For
// filter_eval_failed, CEL evaluation failures name objects;
// findings_scan_incomplete names report kinds with unknown namespaces when
// the findings scan runs out of time. Within one Coverage
// a namespace is in at most one gap.
//
// Action is what the code calls for, from the same table as readreason, so a
// client that doesn't know a newer code still knows what to do. Producers
// leave it empty; marshaling fills it.
type Gap struct {
	Code       readreason.Code   `json:"code"`
	Action     readreason.Action `json:"action,omitempty"`
	Kinds      *KindSet          `json:"kinds,omitempty"`
	Namespaces *NameSet          `json:"namespaces,omitempty"`
	Objects    *ObjectSet        `json:"objects,omitempty"`
}

// MarshalJSON writes the code's action beside it.
func (g Gap) MarshalJSON() ([]byte, error) {
	if g.Action == "" {
		g.Action, _ = readreason.ActionFor(g.Code)
	}
	type wire Gap
	return json.Marshal(wire(g))
}

// Coverage qualifies a single-kind read: a list, or the per-kind entries of a
// count. Status is relative to Scope: complete means Radar's initial read of
// all of it has loaded, and will keep meaning that; freshness or paging, if
// added, come as separate fields. An explicit target (namespaces the request
// named) stays the scope, and what of it could not be read is a gap; an
// implicitly broad request's scope is what remained after NarrowedBy, so
// "complete" there means complete within that narrowed scope only.
//
// Read is what Radar read within Scope, on every successful answer. Read and
// the gaps don't overlap and together make up Scope, so a complete answer's
// Read equals its Scope. An error body never has Read: it returns no rows,
// though Radar may have read some and discarded them (its connection changed
// mid-read, or the answer was over its size budget).
//
// A client meeting a value it doesn't know treats it conservatively: an
// unknown code as an incomplete answer with no remedy guessed beyond its
// Action, an unknown narrowing as a narrowed one, and an unknown scope type as
// grounds for no completeness or absence claim.
type Coverage struct {
	Status     CoverageStatus `json:"status"`
	Scope      Scope          `json:"scope"`
	NarrowedBy []Narrowing    `json:"narrowedBy,omitempty"`
	Read       *Scope         `json:"read,omitempty"`
	Gaps       []Gap          `json:"gaps"`
}

// Validate reports the first way c breaks the contract above, for producers'
// tests. It checks status and the namespace partition, not scope type values
// or a gap's kinds and objects: status agrees with the gaps; scope and read carry a set exactly when
// their type is namespaces; a namespaced scope's gaps all state namespaces
// and a cluster scope's none; listed sets are sorted, unique and as large as
// their count; gaps and read don't overlap; and, where everything is listed,
// read and the gaps make up scope.
func (c Coverage) Validate() error {
	if (c.Status == CoverageComplete) != (len(c.Gaps) == 0) || (c.Status != CoverageComplete && c.Status != CoveragePartial) {
		return fmt.Errorf("status %q with %d gaps", c.Status, len(c.Gaps))
	}
	for _, sc := range []*Scope{&c.Scope, c.Read} {
		if sc == nil {
			continue
		}
		if (sc.Type == ScopeNamespaces) != (sc.Namespaces != nil) {
			return fmt.Errorf("scope type %q with namespaces %v", sc.Type, sc.Namespaces)
		}
		if sc.Namespaces != nil && sc.Namespaces.Extent != "" {
			return fmt.Errorf("an extent belongs on a gap, not a scope: %+v", *sc.Namespaces)
		}
		if err := sc.Namespaces.validListed(); err != nil {
			return err
		}
	}
	if c.Read != nil && c.Read.Type != c.Scope.Type && !(c.Scope.Type == ScopeAllNamespaces && c.Read.Type == ScopeNamespaces) {
		return fmt.Errorf("read type %q within scope type %q", c.Read.Type, c.Scope.Type)
	}
	if c.Status == CoverageComplete && c.Read != nil && !sameScope(c.Scope, *c.Read) {
		return fmt.Errorf("a complete answer's read %+v isn't its scope %+v", *c.Read, c.Scope)
	}
	inScope := func(ns string) bool {
		return c.Scope.Namespaces == nil || c.Scope.Namespaces.Names == nil || slices.Contains(c.Scope.Namespaces.Names, ns)
	}
	wholeGap := slices.ContainsFunc(c.Gaps, func(g Gap) bool { return g.Namespaces != nil && g.Namespaces.Extent == ExtentAll })
	if wholeGap && (len(c.Gaps) > 1 || c.Read != nil) {
		return fmt.Errorf("a gap over the whole scope leaves nothing for read or another gap")
	}
	seen := map[string]bool{}
	if c.Read != nil && c.Read.Namespaces != nil {
		for _, ns := range c.Read.Namespaces.Names {
			if !inScope(ns) {
				return fmt.Errorf("read namespace %q is outside scope", ns)
			}
			seen[ns] = true
		}
	}
	listedTotal := 0
	allListed := c.Read == nil || c.Read.Type == ScopeNamespaces
	if c.Read != nil && c.Read.Namespaces != nil {
		listedTotal = c.Read.Namespaces.Count
	}
	for _, gap := range c.Gaps {
		if gap.Code == "" {
			return fmt.Errorf("gap without a code")
		}
		if gap.Objects != nil {
			if gap.Kinds != nil || gap.Namespaces != nil {
				return fmt.Errorf("gap %s: objects with kinds or namespaces", gap.Code)
			}
			continue
		}
		switch {
		case c.Scope.Type == ScopeCluster && gap.Namespaces != nil:
			return fmt.Errorf("gap %s: namespaces on a cluster-scoped answer", gap.Code)
		case c.Scope.Type != ScopeCluster && gap.Namespaces == nil:
			return fmt.Errorf("gap %s: no namespaces on a namespaced answer", gap.Code)
		}
		if gap.Namespaces == nil {
			continue
		}
		if gap.Namespaces.Extent != "" {
			if gap.Namespaces.Extent != ExtentAll && gap.Namespaces.Extent != ExtentUnknown || gap.Namespaces.Count != 0 || gap.Namespaces.Names != nil {
				return fmt.Errorf("gap %s: extent set %+v", gap.Code, *gap.Namespaces)
			}
			allListed = false
			continue
		}
		if err := gap.Namespaces.validListed(); err != nil {
			return fmt.Errorf("gap %s: %w", gap.Code, err)
		}
		listedTotal += gap.Namespaces.Count
		for _, ns := range gap.Namespaces.Names {
			if !inScope(ns) {
				return fmt.Errorf("gap %s: namespace %q is outside scope", gap.Code, ns)
			}
			if seen[ns] {
				return fmt.Errorf("namespace %q is in two of read and the gaps", ns)
			}
			seen[ns] = true
		}
	}
	if allListed && c.Read != nil && c.Scope.Namespaces != nil && listedTotal != c.Scope.Namespaces.Count {
		return fmt.Errorf("read and gaps count %d namespaces, scope %d", listedTotal, c.Scope.Namespaces.Count)
	}
	if allListed && c.Read != nil && c.Scope.Namespaces != nil && c.Scope.Namespaces.Names != nil && len(seen) == c.Scope.Namespaces.Count {
		for _, ns := range c.Scope.Namespaces.Names {
			if !seen[ns] {
				return fmt.Errorf("scope namespace %q is in neither read nor a gap", ns)
			}
		}
	}
	return nil
}

func sameScope(a, b Scope) bool {
	if a.Type != b.Type || (a.Namespaces == nil) != (b.Namespaces == nil) {
		return false
	}
	return a.Namespaces == nil || a.Namespaces.Count == b.Namespaces.Count && slices.Equal(a.Namespaces.Names, b.Namespaces.Names)
}

// validListed checks a listed set: count not negative, names (when present)
// sorted, unique and exactly count of them. A nil set or an extent set passes.
func (s *NameSet) validListed() error {
	if s == nil || s.Extent != "" {
		return nil
	}
	if s.Count < 0 || s.Names != nil && (len(s.Names) != s.Count || !slices.IsSorted(s.Names) || len(slices.Compact(slices.Clone(s.Names))) != len(s.Names)) {
		return fmt.Errorf("set %+v isn't a sorted list of its %d members", *s, s.Count)
	}
	return nil
}

// MarshalJSON writes no gaps as [], never null: consumers can rely on gaps
// being an array whichever producer built the coverage.
func (c Coverage) MarshalJSON() ([]byte, error) {
	type wire Coverage
	w := wire(c)
	if w.Gaps == nil {
		w.Gaps = []Gap{}
	}
	return json.Marshal(w)
}

// ReadError is the body of a failed resource read: REST responses send it as
// is, and MCP results (structuredContent and the text block) add `next` after
// error_code. A read fails when it returns no usable
// inventory: Radar read nothing in its scope, the answer would be too large,
// or its connection changed mid-read. A failed list's Coverage keeps every
// reason and where it applies, including the ones ErrorCode does not name, and
// never has Read; Error names the same causes in words.
type ReadError struct {
	Error     string          `json:"error"`
	ErrorCode readreason.Code `json:"error_code"`
	Coverage  *Coverage       `json:"coverage,omitempty"`
}

// SearchCoverage qualifies a search. A search reads many kinds across
// namespaces and cluster scope, so it has no single scope or read set: Status
// says whether everything the query named was searched with nothing failing,
// and Gaps what was not. A broad query searches what the user and Radar can
// read without listing each kind or namespace it skipped for access or
// collection; NarrowedBy says that happened, so "complete" never stands for
// an exhaustive negative.
type SearchCoverage struct {
	Status     CoverageStatus `json:"status"`
	NarrowedBy []Narrowing    `json:"narrowedBy,omitempty"`
	Gaps       []Gap          `json:"gaps"`
}

// MarshalJSON writes no gaps as [], never null, as Coverage does.
func (c SearchCoverage) MarshalJSON() ([]byte, error) {
	type wire SearchCoverage
	w := wire(c)
	if w.Gaps == nil {
		w.Gaps = []Gap{}
	}
	return json.Marshal(w)
}