From 701d3b6a02c5ba7687ea411f49f9055d1a6198d7 Mon Sep 17 00:00:00 2001 From: "Patrick W. Healy" Date: Wed, 16 Sep 2026 21:28:50 +0000 Subject: [PATCH] net: share exact overview projection and health semantics Factor legacy peer health and observed-route mismatch calculations into reusable status helpers. Preserve lightweight metadata without carrying peer, route, or BPF arrays. Reuse the calculations for existing controller summaries without changing their output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2243398-6c36-4c3d-969e-7ed7bfb5b459 --- cmd/unbounded-net-controller/status_types.go | 56 +++------ internal/net/status/overview.go | 68 +++++++++++ .../net/status/overview_projection_test.go | 109 ++++++++++++++++++ 3 files changed, 191 insertions(+), 42 deletions(-) create mode 100644 internal/net/status/overview.go create mode 100644 internal/net/status/overview_projection_test.go diff --git a/cmd/unbounded-net-controller/status_types.go b/cmd/unbounded-net-controller/status_types.go index 567a13be5..e6145a30f 100644 --- a/cmd/unbounded-net-controller/status_types.go +++ b/cmd/unbounded-net-controller/status_types.go @@ -9,6 +9,7 @@ import ( "sort" "time" + statuspkg "github.com/Azure/unbounded/internal/net/status" statusv1alpha1 "github.com/Azure/unbounded/internal/net/status/v1alpha1" ) @@ -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 @@ -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) diff --git a/internal/net/status/overview.go b/internal/net/status/overview.go new file mode 100644 index 000000000..1f06c3189 --- /dev/null +++ b/internal/net/status/overview.go @@ -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 +} diff --git a/internal/net/status/overview_projection_test.go b/internal/net/status/overview_projection_test.go new file mode 100644 index 000000000..bcecb795e --- /dev/null +++ b/internal/net/status/overview_projection_test.go @@ -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") + } +}