Ctrl K
ComunidadGuías…

pkg/subject/subject.go

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

Archivo original ↗Preguntar a KiBot
// 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),
	}
}