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
3 changes: 2 additions & 1 deletion cmd/unbounded-net-controller/node_overview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -134,7 +135,7 @@ func TestClusterOverviewPreservesCountsAndEnrichment(t *testing.T) {
overview.NodeErrors = []NodeError{{Type: "cni", Message: "blocked"}}
c.PatchOverview("node", overview)

if buildClusterSummary(snapshot).NodeSummaries[0] != row {
if !reflect.DeepEqual(buildClusterSummary(snapshot).NodeSummaries[0], row) {
t.Fatal("patching changed a previously returned snapshot")
}

Expand Down
38 changes: 22 additions & 16 deletions cmd/unbounded-net-controller/status_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"bytes"
"encoding/json"
"reflect"
"sort"
"time"

Expand Down Expand Up @@ -163,21 +164,23 @@ type ClusterSummary struct {

// NodeSummary is a compact per-node summary for use in ClusterSummary.
type NodeSummary struct {
Name string `json:"name"`
SiteName string `json:"siteName,omitempty"`
IsGateway bool `json:"isGateway,omitempty"`
K8sReady string `json:"k8sReady,omitempty"`
StatusSource string `json:"statusSource,omitempty"`
CniStatus string `json:"cniStatus,omitempty"`
CniTone string `json:"cniTone,omitempty"`
ErrorCount int `json:"errorCount,omitempty"`
FirstError string `json:"firstError,omitempty"`
PeerCount int `json:"peerCount,omitempty"`
HealthyPeers int `json:"healthyPeers,omitempty"`
RouteCount int `json:"routeCount,omitempty"`
RouteMismatch bool `json:"routeMismatch,omitempty"`
FetchError string `json:"fetchError,omitempty"`
WireGuardOnline bool `json:"wireGuardOnline"`
NodeInfo *NodeInfo `json:"nodeInfo,omitempty"`
LastPushTime *time.Time `json:"lastPushTime,omitempty"`
Name string `json:"name"`
SiteName string `json:"siteName,omitempty"`
IsGateway bool `json:"isGateway,omitempty"`
K8sReady string `json:"k8sReady,omitempty"`
StatusSource string `json:"statusSource,omitempty"`
CniStatus string `json:"cniStatus,omitempty"`
CniTone string `json:"cniTone,omitempty"`
ErrorCount int `json:"errorCount,omitempty"`
FirstError string `json:"firstError,omitempty"`
PeerCount int `json:"peerCount,omitempty"`
HealthyPeers int `json:"healthyPeers,omitempty"`
RouteCount int `json:"routeCount,omitempty"`
RouteMismatch bool `json:"routeMismatch,omitempty"`
FetchError string `json:"fetchError,omitempty"`
WireGuardOnline bool `json:"wireGuardOnline"`
}

// buildClusterSummary extracts a ClusterSummary from a full ClusterStatusResponse.
Expand All @@ -195,7 +198,10 @@ func buildClusterSummary(status *ClusterStatusResponse) *ClusterSummary {
overview = &projected
}

info := node.NodeInfo
ns := NodeSummary{
NodeInfo: &info,
LastPushTime: node.LastPushTime,
Name: node.NodeInfo.Name,
SiteName: node.NodeInfo.SiteName,
IsGateway: node.NodeInfo.IsGateway,
Expand Down Expand Up @@ -410,7 +416,7 @@ func computeClusterSummaryDelta(prev, curr *ClusterSummary) *ClusterSummaryDelta

for _, ns := range curr.NodeSummaries {
old, existed := prevByName[ns.Name]
if !existed || ns != old {
if !existed || !reflect.DeepEqual(ns, old) {
delta.NodeSummaries = append(delta.NodeSummaries, ns)
changed = true
}
Expand Down
62 changes: 62 additions & 0 deletions cmd/unbounded-net-controller/summary_metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"

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

func TestSummaryPreservesNodeInfoAndFreshnessWithoutDetails(t *testing.T) {
received := time.Now()
node := retentionFixture(10000)
node.LastPushTime = &received
node.NodeInfo.ProviderID = "azure://vm"
node.NodeInfo.InternalIPs = []string{"192.0.2.1"}
node.NodeInfo.K8sLabels = map[string]string{"node.kubernetes.io/instance-type": "test"}
node.NodeInfo.K8sUpdatedAt = &received
node.NodeInfo.BuildInfo = &statusv1alpha1.BuildInfo{Version: "test"}
cluster := &ClusterStatusResponse{Nodes: []*NodeStatusResponse{&node}}
summary := buildClusterSummary(cluster)

row := summary.NodeSummaries[0]
if row.NodeInfo == &node.NodeInfo || !reflect.DeepEqual(row.NodeInfo, &node.NodeInfo) ||
row.LastPushTime == nil || !row.LastPushTime.Equal(received) {
t.Fatal("summary lost metadata/freshness or retained the full node allocation")
}

data, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}

for _, forbidden := range []string{`"peers"`, `"routingTable"`, `"bpfEntries"`, "private-detail-marker"} {
if strings.Contains(string(data), forbidden) {
t.Fatalf("summary retained diagnostic data: %s", forbidden)
}
}

if delta := computeClusterSummaryDelta(summary, buildClusterSummary(cluster)); delta != nil {
t.Fatalf("equal metadata copies emitted a spurious update: %+v", delta)
}

node.NodeInfo = NodeInfo{Name: "node", ProviderID: "azure://replacement"}

next := buildClusterSummary(cluster)
if delta := computeClusterSummaryDelta(summary, next); delta == nil || len(delta.NodeSummaries) != 1 {
t.Fatal("metadata-only update was omitted")
}

later := received.Add(time.Second)
node.LastPushTime = &later

if delta := computeClusterSummaryDelta(next, buildClusterSummary(cluster)); delta == nil || len(delta.NodeSummaries) != 1 {
t.Fatal("freshness-only update was omitted")
}
}
24 changes: 23 additions & 1 deletion frontend/src/state/clusterSummary.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus, NodeSummary } from '../types';
import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeInfo, NodeStatus, NodeSummary } from '../types';
import type { StatusEvent } from '../api';

export function summarySubscriptionMessage() {
Expand Down Expand Up @@ -45,6 +45,8 @@ export function summarizeNode(node: NodeStatus, now = Date.now()): NodeSummary {
const errorCount = node.nodeErrors?.length || 0;
const source = node.statusSource;
return {
nodeInfo: summaryNodeInfo(node.nodeInfo),
lastPushTime: node.lastPushTime,
name: node.nodeInfo?.name,
siteName: node.nodeInfo?.siteName,
isGateway: node.nodeInfo?.isGateway,
Expand All @@ -61,10 +63,28 @@ export function summarizeNode(node: NodeStatus, now = Date.now()): NodeSummary {
};
}

function summaryNodeInfo(info?: NodeInfo): NodeInfo | undefined {
if (!info) return undefined;
return {
name: info.name, siteName: info.siteName, isGateway: info.isGateway,
k8sReady: info.k8sReady, k8sUpdatedAt: info.k8sUpdatedAt,
podCIDRs: info.podCIDRs, internalIPs: info.internalIPs, externalIPs: info.externalIPs,
providerId: info.providerId, osImage: info.osImage, kernel: info.kernel,
kubelet: info.kubelet, arch: info.arch, nodeOs: info.nodeOs, k8sLabels: info.k8sLabels,
buildInfo: info.buildInfo && {
version: info.buildInfo.version, commit: info.buildInfo.commit, buildTime: info.buildInfo.buildTime,
},
wireGuard: info.wireGuard && {
interface: info.wireGuard.interface, publicKey: info.wireGuard.publicKey, peerCount: info.wireGuard.peerCount,
},
};
}

// Whitelist wire fields, including for already-summary input. Never spread full
// cluster/node objects into persistent state or the global JSON export.
function summaryNode(node: NodeSummary): NodeSummary {
return {
nodeInfo: summaryNodeInfo(node.nodeInfo), lastPushTime: node.lastPushTime,
name: node.name, siteName: node.siteName, isGateway: node.isGateway,
k8sReady: node.k8sReady, statusSource: node.statusSource,
cniStatus: node.cniStatus, cniTone: node.cniTone,
Expand Down Expand Up @@ -123,6 +143,8 @@ export function mergeLegacySummary(current: ClusterSummary | null, delta: Cluste
if (!old) return next;
const merged = {
...old, ...next,
nodeInfo: node.nodeInfo ? next.nodeInfo : old.nodeInfo,
lastPushTime: node.lastPushTime ?? old.lastPushTime,
siteName: node.nodeInfo ? next.siteName : old.siteName,
isGateway: node.nodeInfo ? next.isGateway : old.isGateway,
k8sReady: node.nodeInfo ? next.k8sReady : old.k8sReady,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ export type ClusterSummaryDelta = {
};

export type NodeSummary = {
nodeInfo?: NodeInfo;
lastPushTime?: string;
name?: string;
siteName?: string;
isGateway?: boolean;
Expand Down
28 changes: 28 additions & 0 deletions frontend/tests/clusterSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ test('summary input wins over legacy full fields and is itself whitelisted', ()
assert.equal(JSON.stringify(summary).includes('hidden'), false);
});

test('summary metadata and freshness survive snapshots and deltas without diagnostic arrays', () => {
const nodeInfo = {
name: 'node', internalIPs: ['192.0.2.1'], providerId: 'azure://vm',
k8sReady: 'Ready', k8sUpdatedAt: '2026-09-17T00:00:00Z',
buildInfo: { version: 'test', peers: ['hidden'] },
wireGuard: { interface: 'wg0', publicKey: 'public-key', peers: ['hidden'] },
peers: ['hidden'], bpfEntries: ['hidden'],
};
const projected = toClusterSummary({ nodes: [{
nodeInfo, lastPushTime: '2026-09-17T00:01:00Z', peers: [{ name: 'hidden' }],
}] });
const initial = toClusterSummary({ seq: 1, nodeSummaries: projected.nodeSummaries });
assert.equal(initial.nodeSummaries[0].nodeInfo.providerId, 'azure://vm');
assert.equal(initial.nodeSummaries[0].nodeInfo.wireGuard.publicKey, 'public-key');
assert.equal(initial.nodeSummaries[0].lastPushTime, '2026-09-17T00:01:00Z');
assert.equal(JSON.stringify(initial).includes('hidden'), false);
const next = mergeSummary(initial, {
seq: 2, nodeSummaries: [{ ...initial.nodeSummaries[0], lastPushTime: '2026-09-17T00:02:00Z' }],
});
assert.equal(next.nodeSummaries[0].lastPushTime, '2026-09-17T00:02:00Z');
const legacy = mergeLegacySummary(next, {
seq: 3, updatedNodes: [{ nodeInfo: { ...nodeInfo, kernel: 'new-kernel' } }],
});
assert.equal(legacy.nodeSummaries[0].nodeInfo.kernel, 'new-kernel');
assert.equal(legacy.nodeSummaries[0].lastPushTime, '2026-09-17T00:02:00Z');
assert.equal(JSON.stringify(legacy).includes('hidden'), false);
});

test('CNI priority preserves no-data, errors, unknown and health', () => {
assert.equal(summarizeNode({ statusSource: 'no-data', fetchError: 'error' }).cniStatus, 'No data');
assert.equal(summarizeNode({ fetchError: 'error' }).cniStatus, 'Fetch error');
Expand Down