Skip to content
Closed
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
56 changes: 14 additions & 42 deletions cmd/unbounded-net-controller/status_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"time"

statuspkg "github.com/Azure/unbounded/internal/net/status"
statusv1alpha1 "github.com/Azure/unbounded/internal/net/status/v1alpha1"
)

Expand Down Expand Up @@ -183,23 +184,26 @@ type NodeSummary struct {
}

// buildClusterSummary extracts a ClusterSummary from a full ClusterStatusResponse.
// This is O(N) in nodes with simple field reads -- no route annotation work.
// Legacy payloads require scanning peers and route next hops for overview facts.
func buildClusterSummary(status *ClusterStatusResponse) *ClusterSummary {
summaries := make([]NodeSummary, 0, len(status.Nodes))
now := time.Now()

for i := range status.Nodes {
node := status.Nodes[i]
overview := statuspkg.OverviewFromStatus(node, now)
ns := NodeSummary{
Name: node.NodeInfo.Name,
SiteName: node.NodeInfo.SiteName,
IsGateway: node.NodeInfo.IsGateway,
K8sReady: node.NodeInfo.K8sReady,
StatusSource: node.StatusSource,
PeerCount: len(node.Peers),
RouteCount: len(node.RoutingTable.Routes),
FetchError: node.FetchError,
ErrorCount: len(node.NodeErrors),
Name: node.NodeInfo.Name,
SiteName: node.NodeInfo.SiteName,
IsGateway: node.NodeInfo.IsGateway,
K8sReady: node.NodeInfo.K8sReady,
StatusSource: node.StatusSource,
PeerCount: overview.PeerCount,
HealthyPeers: overview.HealthyPeers,
RouteCount: overview.RouteCount,
RouteMismatch: overview.RouteMismatch,
FetchError: node.FetchError,
ErrorCount: len(node.NodeErrors),
}

// Include first error message so the frontend can show it inline
Expand All @@ -208,38 +212,6 @@ func buildClusterSummary(status *ClusterStatusResponse) *ClusterSummary {
ns.FirstError = node.NodeErrors[0].Message
}

// Count healthy peers
for j := range node.Peers {
peer := &node.Peers[j]
if peer.HealthCheck != nil && peer.HealthCheck.Enabled {
if peer.HealthCheck.Status == "up" || peer.HealthCheck.Status == "Up" {
ns.HealthyPeers++
}
} else {
// Fall back to handshake freshness
if !peer.Tunnel.LastHandshake.IsZero() && now.Sub(peer.Tunnel.LastHandshake) < 3*time.Minute {
ns.HealthyPeers++
}
}
}

// Route mismatch check
for _, route := range node.RoutingTable.Routes {
for _, hop := range route.NextHops {
expected := hop.Expected != nil && *hop.Expected

present := hop.Present != nil && *hop.Present
if expected != present {
ns.RouteMismatch = true
break
}
}

if ns.RouteMismatch {
break
}
}

// Derive CNI status and tone
ns.CniStatus, ns.CniTone = deriveCniStatusAndTone(node, ns.RouteMismatch)
summaries = append(summaries, ns)
Expand Down
68 changes: 68 additions & 0 deletions internal/net/status/overview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

package status

import (
"time"

"github.com/Azure/unbounded/internal/net/status/v1alpha1"
)

// OverviewFromStatus projects a legacy detailed publication into observed facts.
// Node summary collection should compute these facts without collecting details.
func OverviewFromStatus(full *v1alpha1.NodeStatusResponse, now time.Time) v1alpha1.NodeStatusOverview {
overview := v1alpha1.NodeStatusOverview{
Timestamp: full.Timestamp, NodeInfo: full.NodeInfo, HealthCheck: full.HealthCheck,
NodeErrors: full.NodeErrors, FetchError: full.FetchError,
LastPushTime: full.LastPushTime, StatusSource: full.StatusSource, NodePodInfo: full.NodePodInfo,
PeerCount: len(full.Peers), RouteCount: len(full.RoutingTable.Routes),
}
for i := range full.Peers {
if PeerHealthyForOverview(&full.Peers[i], now) {
overview.HealthyPeers++
}
}

for _, route := range full.RoutingTable.Routes {
if RouteMismatchForOverview(route) {
overview.RouteMismatch = true
break
}
}

return overview
}

// OverviewMetadata preserves lightweight fields for controller enrichment.
// It contains no details and must not be returned as a diagnostic snapshot.
func OverviewMetadata(overview v1alpha1.NodeStatusOverview) v1alpha1.NodeStatusResponse {
return v1alpha1.NodeStatusResponse{
Timestamp: overview.Timestamp, NodeInfo: overview.NodeInfo, HealthCheck: overview.HealthCheck,
NodeErrors: overview.NodeErrors, FetchError: overview.FetchError,
LastPushTime: overview.LastPushTime, StatusSource: overview.StatusSource, NodePodInfo: overview.NodePodInfo,
}
}

// PeerHealthyForOverview preserves the dashboard's probe/handshake fallback.
func PeerHealthyForOverview(peer *v1alpha1.PeerStatus, now time.Time) bool {
if peer.HealthCheck != nil && peer.HealthCheck.Enabled {
return peer.HealthCheck.Status == "up" || peer.HealthCheck.Status == "Up"
}

return !peer.Tunnel.LastHandshake.IsZero() && now.Sub(peer.Tunnel.LastHandshake) < 3*time.Minute
}

// RouteMismatchForOverview compares observed and expected next-hop presence.
func RouteMismatchForOverview(route v1alpha1.RouteEntry) bool {
for _, hop := range route.NextHops {
expected := hop.Expected != nil && *hop.Expected

present := hop.Present != nil && *hop.Present
if expected != present {
return true
}
}

return false
}
109 changes: 109 additions & 0 deletions internal/net/status/overview_projection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

package status

import (
"reflect"
"testing"
"time"

"github.com/Azure/unbounded/internal/net/status/v1alpha1"
)

func TestPeerHealthyForOverview(t *testing.T) {
now := time.Unix(1000, 0)

for _, tc := range []struct {
name string
check *v1alpha1.HealthCheckPeerStatus
age time.Duration
missing bool
want bool
}{
{name: "unknown", missing: true},
{name: "recent", age: time.Minute, want: true},
{name: "boundary", age: 3 * time.Minute},
{name: "stale", age: 4 * time.Minute},
{name: "future preserves legacy behavior", age: -time.Minute, want: true},
{name: "up", check: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "up"}, missing: true, want: true},
{name: "Up", check: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "Up"}, missing: true, want: true},
{name: "UP is not up", check: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "UP"}},
{name: "enabled down overrides handshake", check: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "down"}},
{name: "disabled uses handshake", check: &v1alpha1.HealthCheckPeerStatus{Status: "down"}, want: true},
} {
t.Run(tc.name, func(t *testing.T) {
peer := v1alpha1.PeerStatus{HealthCheck: tc.check}
if !tc.missing {
peer.Tunnel.LastHandshake = now.Add(-tc.age)
}

if got := PeerHealthyForOverview(&peer, now); got != tc.want {
t.Fatalf("healthy = %v, want %v", got, tc.want)
}
})
}
}

func TestRouteMismatchForOverview(t *testing.T) {
yes, no := true, false
for _, expected := range []*bool{nil, &no, &yes} {
for _, present := range []*bool{nil, &no, &yes} {
route := v1alpha1.RouteEntry{
NextHops: []v1alpha1.NextHop{{}, {Expected: expected, Present: present}},
}

want := (expected != nil && *expected) != (present != nil && *present)
if got := RouteMismatchForOverview(route); got != want {
t.Fatalf("expected=%v present=%v mismatch=%v, want %v", expected, present, got, want)
}
}
}

if RouteMismatchForOverview(v1alpha1.RouteEntry{}) {
t.Fatal("an empty route has no mismatched hops")
}
}

func TestOverviewProjectionPreservesFactsAndMetadata(t *testing.T) {
now := time.Unix(1000, 0)
yes := true
full := v1alpha1.NodeStatusResponse{
Timestamp: now,
NodeInfo: v1alpha1.NodeInfo{
Name: "node", SiteName: "site", IsGateway: true, K8sReady: "NotReady",
WireGuard: &v1alpha1.WireGuardStatusInfo{Interface: "wg0"},
},
HealthCheck: &v1alpha1.HealthCheckStatus{Healthy: false, Summary: "blocked"},
NodeErrors: []v1alpha1.NodeError{{Type: "cni", Message: "bootstrap blocked"}},
FetchError: "stale", LastPushTime: &now, StatusSource: "stale-cache",
NodePodInfo: &v1alpha1.NodePodInfo{PodName: "pod"},
Peers: []v1alpha1.PeerStatus{
{HealthCheck: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "up"}},
{HealthCheck: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: "down"}},
},
RoutingTable: v1alpha1.RoutingTableInfo{Routes: []v1alpha1.RouteEntry{
{}, {NextHops: []v1alpha1.NextHop{{Expected: &yes}}},
}},
BpfEntries: []v1alpha1.BpfEntry{{CIDR: "10.0.0.0/24"}},
}

overview := OverviewFromStatus(&full, now)
if overview.PeerCount != 2 || overview.HealthyPeers != 1 || overview.RouteCount != 2 || !overview.RouteMismatch {
t.Fatalf("observed facts changed: %+v", overview)
}

metadata := OverviewMetadata(overview)
want := full
want.Peers = nil
want.RoutingTable = v1alpha1.RoutingTableInfo{}

want.BpfEntries = nil
if !reflect.DeepEqual(metadata, want) {
t.Fatalf("metadata changed: got %+v, want %+v", metadata, want)
}

if len(full.Peers) != 2 || len(full.RoutingTable.Routes) != 2 || len(full.BpfEntries) != 1 {
t.Fatal("projection mutated the original snapshot")
}
}