// 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) }