Skip to content
33 changes: 32 additions & 1 deletion internal/issues/dedupe.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ var childCategories = map[issuesapi.Category]bool{
issuesapi.CategoryPVCPending: true,
}

// podCreationChildCategories are the child symptoms that explain a
// ReplicaFailure parent: the controller could not create pods at all, so only a
// rejection of pod creation names the cause. A pod runtime symptom such as a
// crashloop on an existing pod does not, and must not fold it.
var podCreationChildCategories = map[issuesapi.Category]bool{
issuesapi.CategoryQuotaExceeded: true,
issuesapi.CategoryAdmissionWebhookBlocking: true,
issuesapi.CategoryPodSecurityViolation: true,
issuesapi.CategoryRBACForbidden: true,
}

// parentRollupCategories are the workload-level summaries that should be
// suppressed when a more-specific child symptom exists for the same subject.
//
Expand Down Expand Up @@ -139,12 +150,27 @@ func dedupeRepeatedCronJobFailureOverChild(in []Issue) []Issue {
func dedupeWorkloadDegradedOverChild(in []Issue) []Issue {
// Per subject, the worst severity among its specific child-symptom rows.
maxChildSev := map[string]int{}
maxCreationChildSev := map[string]int{}
// A restart loop explains its workload's unavailability at any severity:
// a slow loop or one bad replica is a warning, while the Deployment's
// "N/M available" row is critical whenever a replica is down and comes
// and goes with the crash cycle.
loopChild := map[string]bool{}
for _, i := range in {
if childCategories[i.Category] {
k := subjectKeyOf(subjectRef(i))
if i.RestartLoop != nil {
loopChild[k] = true
}
if r := SeverityRank(i.Severity); r > maxChildSev[k] {
maxChildSev[k] = r
}
// Only the scheduling source's admission rejections are about pod
// creation; the same categories from a running pod (an RBAC
// denial at runtime) are not.
if r := SeverityRank(i.Severity); i.Source == SourceScheduling && podCreationChildCategories[i.Category] && r > maxCreationChildSev[k] {
maxCreationChildSev[k] = r
}
}
}
if len(maxChildSev) == 0 {
Expand All @@ -164,7 +190,12 @@ func dedupeWorkloadDegradedOverChild(in []Issue) []Issue {
// Suppress only when a child at least as severe exists — never
// downgrade a critical rollup to a warning child.
k := subjectKeyOf(subjectRef(i))
if r, ok := maxChildSev[k]; ok && r >= SeverityRank(i.Severity) {
sev := maxChildSev
if i.Reason == "ReplicaFailure" {
sev = maxCreationChildSev
}
loopFolds := i.Category == issuesapi.CategoryWorkloadDegraded && loopChild[k]
if r, ok := sev[k]; loopFolds || (ok && r >= SeverityRank(i.Severity)) {
if i.IssueTiming != "" {
if prev, seen := suppressedIssueTiming[k]; seen && prev != i.IssueTiming {
suppressedIssueTiming[k] = ""
Expand Down
39 changes: 39 additions & 0 deletions internal/issues/dedupe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,45 @@ func TestDedupeWorkloadDegradedOverChild_Phase0(t *testing.T) {
}
})

t.Run("ReplicaFailure is not folded into a crashloop on an existing pod", func(t *testing.T) {
rollout := Issue{Source: SourceProblem, Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web",
Category: issuesapi.CategoryRolloutStalled, Severity: SeverityCritical, Reason: "ReplicaFailure"}
crash := Issue{Source: SourceProblem, Kind: "Pod", Namespace: "ns", Name: "web-abc",
Owner: dep, Category: issuesapi.CategoryCrashLoop, Severity: SeverityCritical}
out := dedupeWorkloadDegradedOverChild([]Issue{rollout, crash})
if !hasCategory(out, issuesapi.CategoryRolloutStalled) {
t.Fatalf("pod creation failure is independent of a crashlooping pod and must survive, got %+v", out)
}
})

t.Run("ReplicaFailure is not folded into a runtime RBAC denial on a running pod", func(t *testing.T) {
rollout := Issue{Source: SourceProblem, Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web",
Category: issuesapi.CategoryRolloutStalled, Severity: SeverityCritical, Reason: "ReplicaFailure"}
runtimeDenial := Issue{Source: SourceProblem, Kind: "Pod", Namespace: "ns", Name: "web-old",
Owner: dep, Category: issuesapi.CategoryRBACForbidden, Severity: SeverityCritical}
out := dedupeWorkloadDegradedOverChild([]Issue{rollout, runtimeDenial})
if !hasCategory(out, issuesapi.CategoryRolloutStalled) {
t.Fatalf("a running pod's RBAC denial does not explain a pod creation failure, got %+v", out)
}
})

t.Run("a warning restart loop folds its workload's critical availability row", func(t *testing.T) {
degraded := Issue{Source: SourceProblem, Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web",
Category: issuesapi.CategoryWorkloadDegraded, Severity: SeverityCritical, Reason: "9/10 available"}
loop := Issue{Source: SourceProblem, Kind: "Pod", Namespace: "ns", Name: "web-abc", Owner: dep,
Category: issuesapi.CategoryCrashLoop, Severity: SeverityWarning, RestartLoop: &issuesapi.RestartLoop{Container: "app"}}
out := dedupeWorkloadDegradedOverChild([]Issue{degraded, loop})
if hasCategory(out, issuesapi.CategoryWorkloadDegraded) {
t.Fatalf("the loop explains the unavailability and must own it at any severity, got %+v", out)
}
stalled := Issue{Source: SourceProblem, Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web",
Category: issuesapi.CategoryRolloutStalled, Severity: SeverityCritical, Reason: "Rollout stuck"}
out = dedupeWorkloadDegradedOverChild([]Issue{stalled, loop})
if !hasCategory(out, issuesapi.CategoryRolloutStalled) {
t.Fatalf("rollout_stalled keeps the severity gate; a warning loop must not hide it, got %+v", out)
}
})

t.Run("cronjob_failed is not a rollup and survives alongside an unrelated job_failed", func(t *testing.T) {
cron := Issue{Source: SourceProblem, Group: "batch", Kind: "CronJob", Namespace: "ns", Name: "nightly",
Category: issuesapi.CategoryCronJobFailed, Severity: SeverityWarning, Reason: "stale"}
Expand Down
48 changes: 48 additions & 0 deletions internal/issues/diagnostic_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"strings"
"time"

"github.com/skyhook-io/radar/internal/k8s"
"github.com/skyhook-io/radar/pkg/issuesapi"
Expand Down Expand Up @@ -973,6 +974,9 @@ func isBlockedInitContainer(i Issue) bool {
}

func restartCauseFact(i Issue) (issuesapi.DiagnosticFact, bool) {
if l := i.RestartLoop; l != nil {
return issuesapi.DiagnosticFact{Type: factRestartCause, Message: restartLoopEvidenceMessage(l)}, true
}
if i.RestartCount <= 0 && i.LastTerminatedReason == "" {
return issuesapi.DiagnosticFact{}, false
}
Expand All @@ -992,6 +996,50 @@ func restartCauseFact(i Issue) (issuesapi.DiagnosticFact, bool) {
}, true
}

// restartLoopEvidenceMessage states what was observed for a looping container.
// Probe failures are listed beside the restarts, never as their cause.
func restartLoopEvidenceMessage(l *issuesapi.RestartLoop) string {
parts := []string{
fmt.Sprintf("container=%s", l.Container),
fmt.Sprintf("restartCount=%d", l.RestartCount),
}
last := fmt.Sprintf("lastExitCode=%d", l.LastExitCode)
if l.LastReason != "" {
last += fmt.Sprintf(" (%s)", l.LastReason)
}
if !l.LastFinishedAt.IsZero() {
last += " at " + l.LastFinishedAt.UTC().Format(time.RFC3339)
}
switch {
case !l.LastStartedAt.IsZero() && !l.LastFinishedAt.IsZero():
last += fmt.Sprintf(" after running %s", l.LastFinishedAt.Sub(l.LastStartedAt).Round(time.Second))
case !l.LastFinishedAt.IsZero():
last += " without starting"
}
parts = append(parts, last)
if l.WorkloadPods > 0 {
parts = append(parts, fmt.Sprintf("loopingPods=%d/%d", l.LoopingPods, l.WorkloadPods))
}
for _, p := range []struct {
name string
pf *issuesapi.ProbeFailure
}{{"startup", l.StartupProbeFailure}, {"liveness", l.LivenessProbeFailure}, {"readiness", l.ReadinessProbeFailure}} {
if p.pf == nil {
continue
}
obs := fmt.Sprintf("%s probe failure last seen %s", p.name, p.pf.LastSeen.UTC().Format(time.RFC3339))
if p.pf.Message != "" {
obs += fmt.Sprintf(" (%q)", p.pf.Message)
}
parts = append(parts, obs)
}
msg := "Restart loop evidence: " + strings.Join(parts, ", ") + "."
if l.SeverityReason != "" {
msg += " Severity " + l.SeverityReason + "."
}
return msg
}

func diagnosticMessage(i Issue) string {
if i.Message != "" {
return i.Message
Expand Down
1 change: 1 addition & 0 deletions internal/issues/grouping.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ func foldGroup(members []Issue) Issue {
Fingerprint: rep.Fingerprint,
RestartCount: rep.RestartCount,
LastTerminatedReason: rep.LastTerminatedReason,
RestartLoop: rep.RestartLoop,
FirstSeen: rep.FirstSeen,
OnsetUnknown: rep.OnsetUnknown,
ResourceCreatedAt: rep.ResourceCreatedAt,
Expand Down
1 change: 1 addition & 0 deletions internal/issues/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ func fromProblem(p k8s.Detection, now time.Time, source Source) Issue {
Count: 1,
RestartCount: p.RestartCount,
LastTerminatedReason: p.LastTerminatedReason,
RestartLoop: p.RestartLoop,
IssueTiming: issueTiming,
IssueTimingBasis: issueTimingBasis,
}
Expand Down
Loading
Loading