Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 92 additions & 13 deletions pkg/topology/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
Expand All @@ -18,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"

"github.com/skyhook-io/radar/pkg/health"
"github.com/skyhook-io/radar/pkg/hpadiag"
"github.com/skyhook-io/radar/pkg/karpenter"
"github.com/skyhook-io/radar/pkg/perfstats"
)
Expand Down Expand Up @@ -3635,7 +3637,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
ID: hpaID,
Kind: KindHPA,
Name: hpa.Name,
Status: StatusHealthy,
Status: hpaNodeHealth(hpa),
Data: map[string]any{
"namespace": hpa.Namespace,
"minReplicas": hpa.Spec.MinReplicas,
Expand Down Expand Up @@ -5300,7 +5302,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
ID: secretNodeID,
Kind: KindSecret,
Name: secretName,
Status: StatusHealthy,
Status: StatusUnknown,
Data: map[string]any{
"namespace": stNs,
"labels": map[string]string{},
Expand Down Expand Up @@ -5338,7 +5340,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
ID: secretNodeID,
Kind: KindSecret,
Name: secretName,
Status: StatusHealthy,
Status: StatusUnknown,
Data: map[string]any{
"namespace": ns,
"labels": map[string]string{},
Expand Down Expand Up @@ -5377,7 +5379,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
ID: secretNodeID,
Kind: KindSecret,
Name: secretName,
Status: StatusHealthy,
Status: StatusUnknown,
Data: map[string]any{
"namespace": ns,
"labels": map[string]string{},
Expand Down Expand Up @@ -5489,7 +5491,7 @@ func (b *Builder) buildResourcesTopology(opts BuildOptions) (*Topology, error) {
ID: secretNodeID,
Kind: KindSecret,
Name: tlsSecretName,
Status: StatusHealthy,
Status: StatusUnknown,
Data: map[string]any{
"namespace": resNs,
"labels": map[string]string{},
Expand Down Expand Up @@ -7882,36 +7884,113 @@ func getGatewayHealth(gw *unstructured.Unstructured) HealthStatus {

// getRouteHealth derives route health from status.parents[].conditions
// All parents Accepted → healthy, some → degraded, none → unhealthy
// An HPA that cannot scale is one of the most common real failures in a cluster,
// and pkg/hpadiag already diagnoses it for the drawer. The graph only ever
// downgrades on negative evidence: routine scaling is not a problem, and MCP
// turns degraded nodes into reported problems (internal/mcp/tools.go), so
// promoting normal autoscaling to amber would manufacture false ones.
func hpaNodeHealth(hpa *autoscalingv2.HorizontalPodAutoscaler) HealthStatus {
// Analyze reports "ok" for an autoscaler the controller has never written a
// condition for, so without this an unreconciled HPA would read as healthy.
if hpa == nil || len(hpa.Status.Conditions) == 0 {
return StatusUnknown
}
diagnosis := hpadiag.Analyze(hpa)
if diagnosis == nil {
return StatusUnknown
}
switch diagnosis.State {
case hpadiag.StateUnableToScale, hpadiag.StateMetricsUnavailable:
return StatusUnhealthy
case hpadiag.StateLimitedMax, hpadiag.StateMetricsIncomplete:
return StatusDegraded
case hpadiag.StateLimitedMin, hpadiag.StateScaledToZero, hpadiag.StateDisabled,
hpadiag.StatePinned, hpadiag.StateStabilized:
// neutral is this vocabulary's "intentional/idle" (types.go), and the
// shipped TS mapping puts exactly these states there.
return StatusNeutral
case hpadiag.StateStale, hpadiag.StateUnknown:
return StatusUnknown
default:
return StatusHealthy
Comment thread
nadaverell marked this conversation as resolved.
Comment thread
nadaverell marked this conversation as resolved.
}
}

func getRouteHealth(route *unstructured.Unstructured) HealthStatus {
parents, _, _ := unstructured.NestedSlice(route.Object, "status", "parents")
if len(parents) == 0 {
return StatusUnknown
}
accepted := 0
generation, _, _ := unstructured.NestedInt64(route.Object, "metadata", "generation")

reporting, accepted, notAccepted, resolved, unresolved, unassessed := 0, 0, 0, 0, 0, 0
for _, p := range parents {
pMap, ok := p.(map[string]any)
if !ok {
continue
}
var acceptedStatus, resolvedStatus string
conditions, _, _ := unstructured.NestedSlice(pMap, "conditions")
for _, c := range conditions {
cMap, ok := c.(map[string]any)
if !ok {
continue
}
if cMap["type"] == "Accepted" && cMap["status"] == "True" {
accepted++
break
// A condition observed against an older spec does not describe the
// route as it stands now. The TS accessor does not check this; it is
// added here deliberately, not mirrored.
if og, found, err := unstructured.NestedInt64(cMap, "observedGeneration"); found && err == nil && generation != 0 && og != generation {
continue
}
switch cMap["type"] {
case "Accepted":
acceptedStatus, _ = cMap["status"].(string)
case "ResolvedRefs":
resolvedStatus, _ = cMap["status"].(string)
}
}
if acceptedStatus == "" && resolvedStatus == "" {
// A parent whose conditions are absent or all stale has not been
// assessed. Dropping it would let the parents that did report speak
// for one nothing has confirmed.
unassessed++
continue
}
reporting++
switch acceptedStatus {
case "True":
accepted++
case "False":
notAccepted++
}
// The gateway accepting the route says nothing about whether the
// backends it forwards to exist; an unresolved backendRef is the
// failure an operator actually opens the graph to find.
switch resolvedStatus {
case "True":
resolved++
case "False":
unresolved++
}
}
if accepted == len(parents) {
return StatusHealthy

if reporting == 0 {
return StatusUnknown
}
if accepted > 0 {
// A total failure is only established once every parent has spoken.
if notAccepted == reporting && unassessed == 0 {
return StatusUnhealthy
Comment thread
cursor[bot] marked this conversation as resolved.
}
if notAccepted > 0 || unresolved > 0 {
return StatusDegraded
}
return StatusUnhealthy
// Accepted alone does not confirm the backends resolve. A parent that has
// not published ResolvedRefs has not resolved them yet, which is the whole
// reason this function reads the condition.
if accepted == reporting && resolved == reporting && unassessed == 0 {
return StatusHealthy
}
return StatusDegraded
}

func sealedSecretHealth(resource *unstructured.Unstructured) HealthStatus {
Expand Down
211 changes: 211 additions & 0 deletions pkg/topology/node_health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package topology

import (
"testing"

autoscalingv2 "k8s.io/api/autoscaling/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// A green node in the graph is also a "no problems" answer from MCP
// (internal/mcp/tools.go builds its problem list from degraded/unhealthy nodes),
// so these pin the line between declared and confirmed.

func routeWithParents(generation int64, parents ...map[string]any) *unstructured.Unstructured {
list := make([]any, 0, len(parents))
for _, p := range parents {
list = append(list, p)
}
return &unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"generation": generation},
"status": map[string]any{"parents": list},
}}
}

func parent(conds ...map[string]any) map[string]any {
list := make([]any, 0, len(conds))
for _, c := range conds {
list = append(list, c)
}
return map[string]any{"conditions": list}
}

func cond(condType, status string, generation int64) map[string]any {
return map[string]any{"type": condType, "status": status, "observedGeneration": generation}
}

func TestGetRouteHealth(t *testing.T) {
tests := []struct {
name string
route *unstructured.Unstructured
want HealthStatus
}{
{
// The bug: a gateway accepting the route says nothing about whether
// the backends it forwards to exist.
name: "accepted but backend refs unresolved",
route: routeWithParents(1, parent(cond("Accepted", "True", 1), cond("ResolvedRefs", "False", 1))),
want: StatusDegraded,
},
{
name: "accepted and refs resolved",
route: routeWithParents(1, parent(cond("Accepted", "True", 1), cond("ResolvedRefs", "True", 1))),
want: StatusHealthy,
},
{
name: "every parent rejected",
route: routeWithParents(1, parent(cond("Accepted", "False", 1))),
want: StatusUnhealthy,
},
{
name: "one parent rejected among several",
route: routeWithParents(1,
parent(cond("Accepted", "True", 1), cond("ResolvedRefs", "True", 1)),
parent(cond("Accepted", "False", 1))),
want: StatusDegraded,
},
{
// One parent healthy and one unassessed is not a healthy route: the
// parent that reported must not speak for the one that did not.
name: "one parent current and healthy, another stale",
route: routeWithParents(7,
parent(cond("Accepted", "True", 7), cond("ResolvedRefs", "True", 7)),
parent(cond("Accepted", "False", 3))),
want: StatusDegraded,
},
{
name: "one parent current and healthy, another reporting nothing",
route: routeWithParents(1,
parent(cond("Accepted", "True", 1), cond("ResolvedRefs", "True", 1)),
parent()),
want: StatusDegraded,
},
{
name: "no parent reports at all",
route: routeWithParents(1),
want: StatusUnknown,
},
{
// Conditions describing an older spec are not evidence about this one.
name: "conditions observed against an older generation",
route: routeWithParents(7, parent(cond("Accepted", "True", 3), cond("ResolvedRefs", "True", 3))),
want: StatusUnknown,
},
{
// Accepted alone is not confirmation the backends resolve.
name: "accepted with no ResolvedRefs reported yet",
route: routeWithParents(1, parent(cond("Accepted", "True", 1))),
want: StatusDegraded,
},
{
// A rejection plus a silent parent is not an established total failure.
name: "one parent rejected, another unassessed",
route: routeWithParents(1,
parent(cond("Accepted", "False", 1)),
parent()),
want: StatusDegraded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getRouteHealth(tt.route); got != tt.want {
t.Errorf("getRouteHealth() = %q, want %q", got, tt.want)
}
})
}
}

func hpaWith(conds ...autoscalingv2.HorizontalPodAutoscalerCondition) *autoscalingv2.HorizontalPodAutoscaler {
min := int32(1)
return &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "prod"},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
MinReplicas: &min,
MaxReplicas: 10,
},
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
CurrentReplicas: 2,
DesiredReplicas: 2,
Conditions: conds,
},
}
}

func TestHPANodeHealth(t *testing.T) {
t.Run("reports an HPA that cannot fetch metrics", func(t *testing.T) {
hpa := hpaWith(
autoscalingv2.HorizontalPodAutoscalerCondition{
Type: "AbleToScale", Status: "True",
},
autoscalingv2.HorizontalPodAutoscalerCondition{
Type: "ScalingActive", Status: "False", Reason: "FailedGetResourceMetric",
},
)
if got := hpaNodeHealth(hpa); got == StatusHealthy {
t.Errorf("hpaNodeHealth() = %q, want a non-healthy status for unavailable metrics", got)
}
})

t.Run("reports an HPA that cannot scale", func(t *testing.T) {
hpa := hpaWith(autoscalingv2.HorizontalPodAutoscalerCondition{
Type: "AbleToScale", Status: "False", Reason: "FailedGetScale",
})
if got := hpaNodeHealth(hpa); got != StatusUnhealthy {
t.Errorf("hpaNodeHealth() = %q, want %q", got, StatusUnhealthy)
}
})

t.Run("does not manufacture a problem out of a healthy HPA", func(t *testing.T) {
hpa := hpaWith(
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "AbleToScale", Status: "True"},
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "ScalingActive", Status: "True"},
)
if got := hpaNodeHealth(hpa); got != StatusHealthy {
t.Errorf("hpaNodeHealth() = %q, want %q", got, StatusHealthy)
}
})

t.Run("does not report routine scaling as a problem", func(t *testing.T) {
// MCP turns degraded nodes into reported problems, so an HPA doing
// exactly its job must not become one.
hpa := hpaWith(
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "AbleToScale", Status: "True"},
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "ScalingActive", Status: "True"},
)
hpa.Status.CurrentReplicas = 2
hpa.Status.DesiredReplicas = 5
if got := hpaNodeHealth(hpa); got == StatusUnhealthy || got == StatusDegraded {
t.Errorf("hpaNodeHealth() = %q, want scaling not to be reported as a problem", got)
}
})

t.Run("says nothing about an autoscaler the controller has not reported on", func(t *testing.T) {
// hpadiag.Analyze returns "ok" for a conditionless HPA, so absence of
// evidence must not reach the graph as health.
hpa := hpaWith()
if got := hpaNodeHealth(hpa); got != StatusUnknown {
t.Errorf("hpaNodeHealth() = %q, want %q for an unreconciled HPA", got, StatusUnknown)
}
})

t.Run("treats a deliberately idle autoscaler as idle, not healthy", func(t *testing.T) {
zero := int32(0)
hpa := hpaWith(
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "AbleToScale", Status: "True"},
autoscalingv2.HorizontalPodAutoscalerCondition{Type: "ScalingActive", Status: "False", Reason: "ScalingDisabled"},
)
hpa.Spec.MinReplicas = &zero
hpa.Status.CurrentReplicas = 0
hpa.Status.DesiredReplicas = 0
if got := hpaNodeHealth(hpa); got != StatusNeutral {
t.Errorf("hpaNodeHealth() = %q, want %q", got, StatusNeutral)
}
})

t.Run("says nothing when there is no HPA", func(t *testing.T) {
if got := hpaNodeHealth(nil); got != StatusUnknown {
t.Errorf("hpaNodeHealth(nil) = %q, want %q", got, StatusUnknown)
}
})
}
Loading