pkg/subject/subject.go
Rama main · réplica de lectura del repositorio original.
// Package subject is the shared resource-identity + grouping vocabulary for the
// platform. internal/issues is its production consumer today: it keys on
// ScopeForKind + StableID at depth-0 (see internal/issues/identity.go). The
// Tier-1 owner-walk and Tier-2 AppOverlay engine are the canonical model that
// pkg/topology's currently-independent logic (top_metrics.go:topOwnerForPod,
// pod_grouping.go:determineGroupKey, managedby.go) is meant to migrate ONTO —
// planned, not yet wired. Until that lands this package is the agreed
// vocabulary, NOT a second live "truth": topology still resolves its own
// grouping, so don't treat subject.Resolve as the running topology path yet.
//
// Tier-1 Subject (owner-collapsed root controller, deterministic, label-free)
// Tier-2 AppOverlay (declared-key 8-tier precedence, provenance/confidence/conflicts)
//
// PLACEMENT NOTE: pkg/subject imports only the canonical-key helper from
// pkg/resourceid (ResourceKey). It does NOT import internal/* or pkg/topology (the
// plan's layering rule). The Tier-1 owner walk is parameterized over an
// OwnerResolver interface — which a topology adapter must satisfy with a
// CONTROLLER-ownership resolver, NOT a raw walkTopmostOwner (see OwnerResolver's
// contract: EdgeManages also carries GitOps/Helm management edges, which are
// Tier-2, not identity). A heuristic single-pod walk is built in. Tier-2 takes
// only a metav1.Object's labels/annotations, so it has zero topology dependency.
package subject
import (
"crypto/sha256"
"encoding/hex"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/skyhook-io/radar/pkg/resourceid"
)
// ============================= TIER 1: SUBJECT =============================
// Scope is the coarse bucket of a Subject — the UI section AND a hash input to
// the issue/check ID. Shared by issues + topology so they key on one enum.
// `unknown` is first-class.
type Scope string
const (
ScopeUnknown Scope = "unknown"
ScopeWorkload Scope = "workload"
ScopeService Scope = "service"
ScopeIngress Scope = "ingress"
ScopePVC Scope = "pvc"
ScopeNode Scope = "node"
)
// Ref is the canonical {group,kind,namespace,name} identity. Field-identical to
// issues.Ref (a plain struct copy converts); topology.ResourceRef orders its
// fields differently and needs field-by-field conversion. Group empty => core group.
type Ref struct {
Group string `json:"group,omitempty"`
Kind string `json:"kind"`
Namespace string `json:"namespace,omitempty"`
Name string `json:"name"`
}
// Anchor names the explicit Tier-1 terminal bucket a Subject resolved into. It
// makes the bare-pod and static/mirror-pod terminals first-class instead of
// hiding them under "Pod", and records the operator-CR hop.
type Anchor string
const (
AnchorOwnerCollapsed Anchor = "owner_collapsed" // Pod->RS->Deployment, Pod->Job->CronJob
AnchorBare Anchor = "bare" // owner=nil (was pod_grouping "standalone")
AnchorNode Anchor = "node" // owner=Node (static/mirror pod)
AnchorOperatorCR Anchor = "operator_cr" // CNPG Cluster / Strimzi Kafka / Crossplane XR
AnchorSelf Anchor = "self" // non-pod resource is its own subject (CRDs, Service, PVC…)
)
// Subject is the deterministic Tier-1 identity spine. Ref is the owner-collapsed
// root controller; Scope = ScopeForKind(Ref.Kind); Anchor records HOW it
// resolved. Derived purely from CONTROLLER ownership (ownerReferences[].
// controller, or controllerRef-derived topology edges) — never from declarative
// "management" edges (Argo/Flux/Helm), which are Tier-2 AppOverlay.
type Subject struct {
Ref Ref
Scope Scope
Anchor Anchor
}
// Key is the canonical group|kind|namespace|name string for s.Ref — a thin
// pass-through to pkg/resourceid.ResourceKey (NOT reinvented). This is the
// groupingKey fed to StableID; sharing it keeps issue grouping and audit
// deep-links from drifting.
func (s Subject) Key() string {
return resourceid.ResourceKey(s.Ref.Group, s.Ref.Kind, s.Ref.Namespace, s.Ref.Name)
}
// ScopeForKind maps a Kind to its Scope, defaulting to unknown (NOT an error)
// for unrecognized kinds. Exported so checks can reuse it.
func ScopeForKind(kind string) Scope {
switch kind {
case "Pod", "Deployment", "Rollout", "StatefulSet", "DaemonSet", "ReplicaSet", "Job", "CronJob":
return ScopeWorkload
case "Service":
return ScopeService
case "Ingress":
return ScopeIngress
case "PersistentVolumeClaim":
return ScopePVC
case "Node":
return ScopeNode
}
return ScopeUnknown
}
// OwnerResolver walks Tier-1 identity to the topmost controller WITHOUT
// pkg/subject importing pkg/topology.
//
// CONTRACT — Kubernetes CONTROLLER ownership only. ParentOf must follow the
// ownerReferences[].controller==true chain (Pod→ReplicaSet→Deployment,
// Pod→Job→CronJob, generated-workload→operator-CR). It MUST NOT follow
// declarative/application "management" relationships — an Argo Application,
// Flux Kustomization, or HelmRelease "manages" a Deployment, but it does NOT
// *own* it in the controller sense. Those belong in Tier-2 AppOverlay, never in
// the Subject. Collapsing a Deployment up to its Application here would erase
// the identity/overlay boundary the whole package exists to keep.
//
// CAUTION for a future topology adapter: do NOT wrap pkg/topology's
// walkTopmostOwner directly — it follows every EdgeManages edge, and EdgeManages
// is deliberately overloaded in the graph to include GitOps/Helm management
// edges (Argo Application→resource, GitRepository→Kustomization, …). Resolve
// from controllerReferences instead (the cache-aware k8s.topOwnerForPodResolved
// is the reference implementation), or filter the walk to controllerRef-derived
// edges. internal/issues passes an owner that is already controller-resolved by
// the detectors (a depth-0/1 walk).
//
// Returns the immediate controller-parent of the given ref, or (zero, false)
// when there is no further controller. Names already RS-hash-stripped per impl.
type OwnerResolver interface {
// ParentOf returns the next CONTROLLER up the chain, or (zero, false) at root.
ParentOf(child Ref) (parent Ref, ok bool)
}
// OperatorRootHook lets the resolver hop ONE level above an operator-generated
// workload to the operator CR (CNPG Cluster owns the StatefulSet, etc.). V1
// ships a curated allowlist impl (DefaultOperatorRoots); unknown operator CRs
// return false and degrade to the workload (raw-always).
type OperatorRootHook interface {
// RootFor returns the operator-CR root owning `workload`, if recognized.
RootFor(workload Ref) (root Ref, ok bool)
}
// maxOwnerWalkDepth bounds the multi-hop owner walk. Real K8s ownership chains
// are shallow (Pod->RS->Deployment is depth 2); a depth this high can only be
// reached by a corrupted/cyclic topology, and the built-in visited-set already
// stops cycles — this is belt-and-suspenders.
const maxOwnerWalkDepth = 16
// ResolveSubject is the Tier-1 entrypoint. It collapses ownerReferences to the
// root controller and classifies the terminal Anchor. It SUBSUMES:
// - topOwnerForPod's single-hop RS->Deployment + name-strip (generalized to a
// multi-hop walk via OwnerResolver), AND extends it with Job->CronJob,
// owner=nil -> AnchorBare, owner=Node -> AnchorNode, operator-CR root hop.
// - enrichIdentity / foldGroup's owner-else-self collapse (the single hop is
// just a depth-1 walk with no further parent).
//
// Pass owners=nil for the pure "resource is its own subject" path. The
// owner-walk inputs (topo/dp/idx) are bound inside the OwnerResolver the caller
// injects, so this signature stays layering-clean.
func ResolveSubject(start Ref, owners OwnerResolver, ops OperatorRootHook) Subject {
cur := start
anchor := AnchorSelf
if owners != nil {
visited := map[string]bool{refKey(cur): true}
chain := []Ref{cur}
for i := 0; i < maxOwnerWalkDepth; i++ {
parent, ok := owners.ParentOf(cur)
if !ok {
break
}
// owner=Node is a terminal bucket for static/mirror pods — never
// collapse "up into" the Node as if it owned a workload.
if parent.Kind == "Node" {
anchor = AnchorNode
break
}
if visited[refKey(parent)] {
// Corrupt ownership cycle (e.g. A→B→A). A canonical identity must
// be start-independent, so collapse every member to ONE
// deterministic representative — the lexicographically smallest
// key walked — instead of returning the last hop, which would
// depend on where the walk began (resolving A→B but B→A).
cur = minRef(chain)
anchor = AnchorOwnerCollapsed
break
}
visited[refKey(parent)] = true
chain = append(chain, parent)
cur = parent
anchor = AnchorOwnerCollapsed
}
}
// A pod with no owner (and no owner=Node terminal) is bare/standalone.
if anchor == AnchorSelf && start.Kind == "Pod" {
anchor = AnchorBare
}
// Operator-CR root hop: one level ABOVE the generated workload. Only when
// the resolved controller is itself owned by a recognized operator CR.
if ops != nil && anchor != AnchorNode {
if root, ok := ops.RootFor(cur); ok {
cur = root
anchor = AnchorOperatorCR
}
}
return Subject{
Ref: cur,
Scope: ScopeForKind(cur.Kind),
Anchor: anchor,
}
}
func refKey(r Ref) string {
return resourceid.ResourceKey(r.Group, r.Kind, r.Namespace, r.Name)
}
// minRef returns the ref with the lexicographically smallest key — the
// deterministic, start-independent representative of an ownership cycle.
func minRef(refs []Ref) Ref {
best := refs[0]
bestKey := refKey(best)
for _, r := range refs[1:] {
if k := refKey(r); k < bestKey {
best, bestKey = r, k
}
}
return best
}
// HeuristicPodOwnerResolver is the built-in single-pod OwnerResolver: walks
// pod.OwnerReferences[].controller==true (controller-only, per the contract;
// pods with no controller ref resolve to no owner), RS->Deployment +
// StripReplicaSetHash, Job stays Job (CronJob hop handled by the multi-hop loop
// when a topology OwnerResolver provides the Job->CronJob parent).
//
// It resolves a parent ONLY for the Pod itself: it returns the Pod's controller
// once, then (zero, false) for anything else, since a bare Pod's
// OwnerReferences only describe one hop. Job->CronJob and deeper chains require
// a topology-backed OwnerResolver.
//
// APPROXIMATION (read before using): for a ReplicaSet owner it assumes
// RS->Deployment by stripping the pod-template hash. That is WRONG for
// ReplicaSets owned by an Argo Rollout or any other CRD controller — it
// fabricates a Deployment that doesn't exist (the exact phantom-owner class
// fixed in the cache-aware k8s.topOwnerForPodResolved path). Use this only as
// the label-free, no-cache fallback; when a cache/topology is available, inject
// an OwnerResolver that resolves the ReplicaSet's *real* controller instead.
type HeuristicPodOwnerResolver struct{ Pod metav1.Object }
func (p HeuristicPodOwnerResolver) ParentOf(child Ref) (Ref, bool) {
if p.Pod == nil {
return Ref{}, false
}
// Only the pod itself has a known parent in this single-pod resolver.
if child.Kind != "Pod" || child.Name != p.Pod.GetName() || child.Namespace != p.Pod.GetNamespace() {
return Ref{}, false
}
refs := p.Pod.GetOwnerReferences()
pick := func(ref metav1.OwnerReference) (Ref, bool) {
if ref.Kind == "ReplicaSet" {
return Ref{Group: "apps", Kind: "Deployment", Namespace: p.Pod.GetNamespace(), Name: StripReplicaSetHash(ref.Name)}, true
}
return Ref{Group: resourceid.GroupFromAPIVersion(ref.APIVersion), Kind: ref.Kind, Namespace: p.Pod.GetNamespace(), Name: ref.Name}, true
}
for _, ref := range refs {
if ref.Controller != nil && *ref.Controller {
return pick(ref)
}
}
// Controller-only, per the OwnerResolver contract: a pod with only
// NON-controller ownerRefs has no canonical owner — it is its own subject.
// (topOwnerForPod historically fell back to refs[0], collapsing pods under
// an arbitrary non-controller owner; that is exactly the "management edge as
// identity" the contract forbids, so it's dropped here.)
return Ref{}, false
}
// StripReplicaSetHash moves verbatim from top_metrics.go (idx<=0 guard preserved).
func StripReplicaSetHash(name string) string {
idx := strings.LastIndex(name, "-")
if idx <= 0 {
return name
}
return name[:idx]
}
// ============================ DETERMINISTIC IDs ============================
// StableID is the generic deterministic identity hash: sha256 of
// scope + "\x00" + groupingKey + "\x00" + discriminator, truncated [:8], hex.
// Subject produces subject identity; a surface composes its own ID from a
// subject key + a discriminator (Issues uses category; Checks could use a
// check-id). The exact byte layout is load-bearing — any change re-keys every
// existing record — so it must not be altered.
func StableID(scope Scope, groupingKey, discriminator string) string {
sum := sha256.Sum256([]byte(string(scope) + "\x00" + groupingKey + "\x00" + discriminator))
return hex.EncodeToString(sum[:8])
}
// ============================ UNIFIED ENTRYPOINT ============================
// Resolved is what every surface keys off: the deterministic Subject + optional
// overlay. App is nil whenever raw wins.
type Resolved struct {
Subject Subject
App *AppOverlay
}
// Resolve composes Tier-1 + Tier-2. Tier-1 always populates Subject (never
// fails, never needs a label). Tier-2 is best-effort.
//
// IMPORTANT — the two inputs feed different tiers and are NOT interchangeable:
// - owners drives ownership (Tier-1). obj is NOT consulted for the owner walk;
// pass owners=nil only when start truly has no controller (Resolve(ref, obj,
// nil, …) yields a bare/self Subject even if obj carries ownerRefs). To walk
// a pod's owners, inject a resolver — HeuristicPodOwnerResolver{Pod: obj} for
// the no-cache path, or a cache/controllerRef-backed one in production.
// - obj supplies ONLY the labels/annotations for the Tier-2 overlay.
func Resolve(start Ref, obj metav1.Object, owners OwnerResolver, ops OperatorRootHook, allowBareApp bool) Resolved {
return Resolved{
Subject: ResolveSubject(start, owners, ops),
App: ResolveOverlay(obj, allowBareApp),
}
}