pkg/k8score/object_warnings.go
Rama main · réplica de lectura del repositorio original.
package k8score
import (
"fmt"
"strings"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"github.com/skyhook-io/radar/pkg/gitops"
"github.com/skyhook-io/radar/pkg/resourceid"
)
// EnrichRuntimeObjectWarnings converts a typed K8s runtime.Object to
// *unstructured.Unstructured (if needed) and runs EnrichObjectWarnings.
// Returns nil on conversion error — best-effort, never blocks the caller.
func EnrichRuntimeObjectWarnings(obj runtime.Object) []string {
return enrichRuntime(obj, true)
}
// EnrichRuntimeObjectAnsweredWarnings is EnrichRuntimeObjectWarnings for a
// caller that reports a GitOps manager answer beside the warnings: the answer
// weighs the Argo CD, Flux and Helm claims the metadata makes, so a warning
// naming one of those is left out. Metadata it doesn't weigh keeps its
// warning: another controller's app.kubernetes.io/managed-by label, or Helm
// named by labels without its release annotation.
func EnrichRuntimeObjectAnsweredWarnings(obj runtime.Object) []string {
return enrichRuntime(obj, false)
}
func enrichRuntime(obj runtime.Object, withManager bool) []string {
if obj == nil {
return nil
}
if u, ok := obj.(*unstructured.Unstructured); ok {
return enrich(u, withManager)
}
raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil
}
return enrich(&unstructured.Unstructured{Object: raw}, withManager)
}
// EnrichObjectWarnings derives advisory warnings purely from an object's own
// state — no API calls. Safe to invoke per-row on list responses. Surfaces
// state that an agent commonly skims past (deletionTimestamp, terminating
// namespace, never-healthy workloads, external reconciliation by Helm/Argo/Flux).
//
// All entries are self-contained factual sentences. Empty result means
// "nothing notable about this object's state."
func EnrichObjectWarnings(obj *unstructured.Unstructured) []string {
return enrich(obj, true)
}
// enrich derives the warnings; without withManager, the caller reports a
// manager answer that covers the GitOps and Helm metadata.
func enrich(obj *unstructured.Unstructured, withManager bool) []string {
if obj == nil {
return nil
}
var out []string
// Beside a manager answer, the first manager its claims don't already
// cover: an independent controller label, or Helm named by labels alone.
for _, m := range externalManagers(obj) {
if withManager || !m.claim {
out = append(out, formatExternalManagerWarning(m.name, m.instance))
break
}
}
if w := deletionTimestampWarning(obj); w != "" {
out = append(out, w)
}
if w := namespaceTerminatingWarning(obj); w != "" {
out = append(out, w)
}
if w := workloadHealthWarning(obj, time.Now()); w != "" {
out = append(out, w)
}
if w := pvcPendingWarning(obj); w != "" {
out = append(out, w)
}
return out
}
func deletionTimestampWarning(obj *unstructured.Unstructured) string {
dt := obj.GetDeletionTimestamp()
if dt == nil || dt.IsZero() {
return ""
}
finalizers := obj.GetFinalizers()
if len(finalizers) == 0 {
return fmt.Sprintf("Resource is being deleted (deletionTimestamp: %s, no finalizers remaining); it may disappear shortly. Any edits will not persist.", dt.Format(time.RFC3339))
}
return fmt.Sprintf("Resource is being deleted (deletionTimestamp: %s) with %d finalizer(s) still active: %s. The resource is in cleanup; edits and reads can behave unexpectedly until finalizers are removed.", dt.Format(time.RFC3339), len(finalizers), strings.Join(finalizers, ", "))
}
func namespaceTerminatingWarning(obj *unstructured.Unstructured) string {
if obj.GetKind() != "Namespace" {
return ""
}
phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase")
if phase != "Terminating" {
return ""
}
return "Namespace is in `Terminating` phase. The apiserver will reject new resource creates in this namespace until termination completes (or until a stuck finalizer is removed)."
}
// workloadHealthWarning surfaces (1) how long the workload's primary health
// condition has been False and (2) whether it went False at about the time the
// resource was created, always with the resource's age. Both forms are facts,
// not an interpretation: a failure present since creation is not evidence that
// it is old or unrelated to an incident, because a resource created seconds ago
// carries the same marker as one that has been broken for weeks. Only the age
// tells them apart.
func workloadHealthWarning(obj *unstructured.Unstructured, now time.Time) string {
kind := obj.GetKind()
var conditionType string
switch kind {
case "Deployment", "StatefulSet":
conditionType = "Available"
case "Pod":
conditionType = "Ready"
default:
return ""
}
conds, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions")
if len(conds) == 0 {
return ""
}
var failingFor time.Duration
var foundFailing bool
for _, c := range conds {
cm, ok := c.(map[string]any)
if !ok {
continue
}
cType, _ := cm["type"].(string)
if cType != conditionType {
continue
}
cStatus, _ := cm["status"].(string)
if cStatus != "False" {
return ""
}
foundFailing = true
if ltt, _ := cm["lastTransitionTime"].(string); ltt != "" {
if t, err := time.Parse(time.RFC3339, ltt); err == nil {
failingFor = now.Sub(t)
}
}
break
}
if !foundFailing {
return ""
}
createdAt := obj.GetCreationTimestamp().Time
if createdAt.IsZero() || failingFor <= 0 {
return ""
}
age := now.Sub(createdAt)
// The condition went False within 30s of creation. The slop accommodates the
// brief moment after creation when conditions haven't been written yet; it
// also absorbs a healthy window shorter than that, hence "about".
if age-failingFor < 30*time.Second {
return fmt.Sprintf("Condition `%s=False` for ~%s — since about when this resource was created (resource age: %s).", conditionType, durationShort(failingFor), durationShort(age))
}
return fmt.Sprintf("Condition `%s=False` for ~%s (resource age: %s).", conditionType, durationShort(failingFor), durationShort(age))
}
func pvcPendingWarning(obj *unstructured.Unstructured) string {
if obj.GetKind() != "PersistentVolumeClaim" {
return ""
}
phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase")
if phase != "Pending" {
return ""
}
createdAt := obj.GetCreationTimestamp().Time
if createdAt.IsZero() {
return ""
}
age := time.Since(createdAt)
if age < 30*time.Second {
return ""
}
return fmt.Sprintf("PVC has been `Pending` for %s. Common causes: no matching StorageClass, `volumeBindingMode: WaitForFirstConsumer` with no consumer pod scheduled yet, or capacity exhaustion in the underlying provisioner. Check `status.conditions` and recent Warning events on this PVC.", durationShort(age))
}
func durationShort(d time.Duration) string {
if d < 0 {
d = 0
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
m := int(d.Minutes())
s := int(d.Seconds()) - m*60
if s == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dm%ds", m, s)
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) - h*60
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh%dm", h, m)
}
days := int(d.Hours()) / 24
return fmt.Sprintf("%dd", days)
}
// copiedTrackingID reports whether instance is an Argo CD tracking ID naming
// another object, as on a Secret copied with its source's annotations: Argo CD
// doesn't count such an object as its own.
func copiedTrackingID(obj *unstructured.Unstructured, instance string) bool {
if instance == "" || instance != obj.GetAnnotations()[argoTrackingIDAnnotationKey] {
return false
}
ref := resourceid.Ref{Group: resourceid.GroupFromAPIVersion(obj.GetAPIVersion()), Kind: obj.GetKind(), Namespace: obj.GetNamespace(), Name: obj.GetName()}
for _, c := range gitops.Claims(ref, nil, obj.GetAnnotations()) {
if c.Source == gitops.ClaimArgoTrackingID {
return c.Copied
}
}
return false
}
const argoTrackingIDAnnotationKey = "argocd.argoproj.io/tracking-id"