diff --git a/internal/net/status/overview.go b/internal/net/status/overview.go index 1f06c3189..49d2d0c8c 100644 --- a/internal/net/status/overview.go +++ b/internal/net/status/overview.go @@ -22,15 +22,16 @@ func OverviewFromStatus(full *v1alpha1.NodeStatusResponse, now time.Time) v1alph if PeerHealthyForOverview(&full.Peers[i], now) { overview.HealthyPeers++ } - } - for _, route := range full.RoutingTable.Routes { - if RouteMismatchForOverview(route) { - overview.RouteMismatch = true - break + if full.Peers[i].Tunnel.Protocol == "IPIP" { + overview.UsesIPIP = true } } + overview.UnhealthyPeerLinks = UnhealthyPeerLinkCount(full.Peers, now) + overview.RouteMismatchCount = RouteMismatchCount(full.RoutingTable.Routes) + overview.RouteMismatch = overview.RouteMismatchCount > 0 + return overview } diff --git a/internal/net/status/overview_diagnostics_test.go b/internal/net/status/overview_diagnostics_test.go new file mode 100644 index 000000000..23f24372a --- /dev/null +++ b/internal/net/status/overview_diagnostics_test.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + + statusproto "github.com/Azure/unbounded/internal/net/status/proto" + "github.com/Azure/unbounded/internal/net/status/v1alpha1" +) + +func TestOverviewDiagnosticFactsPreserveLegacyRules(t *testing.T) { + now := time.Unix(1000, 0) + yes := true + full := &v1alpha1.NodeStatusResponse{ + NodeInfo: v1alpha1.NodeInfo{Name: "node", ProviderID: "azure://vm"}, + NodeErrors: []v1alpha1.NodeError{{Type: "cni", Message: "not ready"}}, + Peers: []v1alpha1.PeerStatus{ + {Name: "same", Tunnel: v1alpha1.PeerTunnelStatus{Protocol: "IPIP"}, HealthCheck: &v1alpha1.HealthCheckPeerStatus{Enabled: true, Status: " UP "}}, + {Name: "same", HealthCheck: &v1alpha1.HealthCheckPeerStatus{Status: " up "}}, + {Name: "same", Tunnel: v1alpha1.PeerTunnelStatus{LastHandshake: now}, HealthCheck: &v1alpha1.HealthCheckPeerStatus{Status: " down "}}, + }, + RoutingTable: v1alpha1.RoutingTableInfo{Routes: []v1alpha1.RouteEntry{ + {NextHops: []v1alpha1.NextHop{{Expected: &yes}, {Present: &yes}}}, + {NextHops: []v1alpha1.NextHop{{Expected: &yes}}}, + }}, + } + + got := OverviewFromStatus(full, now) + if got.RouteMismatchCount != 3 || !got.RouteMismatch || got.UnhealthyPeerLinks != 1 || !got.UsesIPIP { + t.Fatalf("diagnostic facts changed: %+v", got) + } + + if got.UnhealthyPeerLinks == got.PeerCount-got.HealthyPeers { + t.Fatal("fixture must distinguish diagnostic normalization from overview peer counts") + } + + if !reflect.DeepEqual(got.NodeErrors, full.NodeErrors) { + t.Fatal("IPIP warning must not be injected into node errors or alter CNI status") + } + + full.Peers = nil + full.RoutingTable.Routes = nil + + got = OverviewFromStatus(full, now) + if got.RouteMismatchCount != 0 || got.RouteMismatch || got.UnhealthyPeerLinks != 0 || got.UsesIPIP { + t.Fatalf("removed diagnostics remained in projection: %+v", got) + } +} + +func TestOverviewDiagnosticWireFields(t *testing.T) { + data, err := json.Marshal(v1alpha1.NodeStatusOverview{}) + if err != nil { + t.Fatal(err) + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + + for _, field := range []string{"routeMismatchCount", "unhealthyPeerLinks", "usesIPIP"} { + if fields[field] == nil { + t.Errorf("zero-valued diagnostic fact %q was omitted", field) + } + } + + want := &statusproto.NodeStatusOverview{RouteMismatchCount: 3, UnhealthyPeerLinks: 2, UsesIpip: true} + + data, err = proto.Marshal(want) + if err != nil { + t.Fatal(err) + } + + got := &statusproto.NodeStatusOverview{} + if err := proto.Unmarshal(data, got); err != nil { + t.Fatal(err) + } + + if !proto.Equal(got, want) { + t.Fatalf("diagnostic facts lost on protobuf round trip: %v", got) + } + + protoFields := got.ProtoReflect().Descriptor().Fields() + for name, number := range map[protoreflect.Name]protoreflect.FieldNumber{ + "route_mismatch_count": 13, "unhealthy_peer_links": 14, "uses_ipip": 15, + } { + if field := protoFields.ByName(name); field == nil || field.Number() != number { + t.Errorf("field %s no longer has number %d", name, number) + } + } +} diff --git a/internal/net/status/proto/status.pb.go b/internal/net/status/proto/status.pb.go index ef23200f3..30d8ee4f1 100644 --- a/internal/net/status/proto/status.pb.go +++ b/internal/net/status/proto/status.pb.go @@ -1893,6 +1893,9 @@ type NodeStatusOverview struct { HealthyPeers int32 `protobuf:"varint,10,opt,name=healthy_peers,json=healthyPeers,proto3" json:"healthy_peers,omitempty"` RouteCount int32 `protobuf:"varint,11,opt,name=route_count,json=routeCount,proto3" json:"route_count,omitempty"` RouteMismatch bool `protobuf:"varint,12,opt,name=route_mismatch,json=routeMismatch,proto3" json:"route_mismatch,omitempty"` + RouteMismatchCount int32 `protobuf:"varint,13,opt,name=route_mismatch_count,json=routeMismatchCount,proto3" json:"route_mismatch_count,omitempty"` + UnhealthyPeerLinks int32 `protobuf:"varint,14,opt,name=unhealthy_peer_links,json=unhealthyPeerLinks,proto3" json:"unhealthy_peer_links,omitempty"` // normalized diagnostic rules, not peer_count - healthy_peers + UsesIpip bool `protobuf:"varint,15,opt,name=uses_ipip,json=usesIpip,proto3" json:"uses_ipip,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2011,6 +2014,27 @@ func (x *NodeStatusOverview) GetRouteMismatch() bool { return false } +func (x *NodeStatusOverview) GetRouteMismatchCount() int32 { + if x != nil { + return x.RouteMismatchCount + } + return 0 +} + +func (x *NodeStatusOverview) GetUnhealthyPeerLinks() int32 { + if x != nil { + return x.UnhealthyPeerLinks + } + return 0 +} + +func (x *NodeStatusOverview) GetUsesIpip() bool { + if x != nil { + return x.UsesIpip + } + return false +} + // DetailRequest uses the same deadline across all delivery attempts. // Standalone commands use ACK status "detail_request", not "ok". type DetailRequest struct { @@ -2261,7 +2285,7 @@ const file_status_proto_rawDesc = "" + "\x03vni\x18\x06 \x01(\rR\x03vni\x12\x10\n" + "\x03mtu\x18\a \x01(\x05R\x03mtu\x12\x18\n" + "\aifindex\x18\b \x01(\rR\aifindex\x12\x18\n" + - "\ahealthy\x18\t \x01(\bR\ahealthy\"\xe0\x04\n" + + "\ahealthy\x18\t \x01(\bR\ahealthy\"\xe1\x05\n" + "\x12NodeStatusOverview\x12*\n" + "\x11timestamp_unix_ns\x18\x01 \x01(\x03R\x0ftimestampUnixNs\x12=\n" + "\tnode_info\x18\x02 \x01(\v2 .unboundednet.status.v1.NodeInfoR\bnodeInfo\x12L\n" + @@ -2279,7 +2303,10 @@ const file_status_proto_rawDesc = "" + " \x01(\x05R\fhealthyPeers\x12\x1f\n" + "\vroute_count\x18\v \x01(\x05R\n" + "routeCount\x12%\n" + - "\x0eroute_mismatch\x18\f \x01(\bR\rrouteMismatch\"X\n" + + "\x0eroute_mismatch\x18\f \x01(\bR\rrouteMismatch\x120\n" + + "\x14route_mismatch_count\x18\r \x01(\x05R\x12routeMismatchCount\x120\n" + + "\x14unhealthy_peer_links\x18\x0e \x01(\x05R\x12unhealthyPeerLinks\x12\x1b\n" + + "\tuses_ipip\x18\x0f \x01(\bR\busesIpip\"X\n" + "\rDetailRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12(\n" + diff --git a/internal/net/status/proto/status.proto b/internal/net/status/proto/status.proto index f276bccb0..84bb57488 100644 --- a/internal/net/status/proto/status.proto +++ b/internal/net/status/proto/status.proto @@ -242,6 +242,9 @@ message NodeStatusOverview { int32 healthy_peers = 10; int32 route_count = 11; bool route_mismatch = 12; + int32 route_mismatch_count = 13; + int32 unhealthy_peer_links = 14; // normalized diagnostic rules, not peer_count - healthy_peers + bool uses_ipip = 15; } // DetailRequest uses the same deadline across all delivery attempts. diff --git a/internal/net/status/v1alpha1/types.go b/internal/net/status/v1alpha1/types.go index 34a7766f4..9466cca64 100644 --- a/internal/net/status/v1alpha1/types.go +++ b/internal/net/status/v1alpha1/types.go @@ -8,18 +8,21 @@ import "time" // NodeStatusOverview contains routine status without peer, route, or BPF details. // Counts and mismatch state are observed facts, not inferred from missing details. type NodeStatusOverview struct { - Timestamp time.Time `json:"timestamp"` - NodeInfo NodeInfo `json:"nodeInfo"` - HealthCheck *HealthCheckStatus `json:"healthCheck,omitempty"` - NodeErrors []NodeError `json:"nodeErrors,omitempty"` - FetchError string `json:"fetchError,omitempty"` - LastPushTime *time.Time `json:"lastPushTime,omitempty"` - StatusSource string `json:"statusSource,omitempty"` - NodePodInfo *NodePodInfo `json:"nodePodInfo,omitempty"` - PeerCount int `json:"peerCount"` - HealthyPeers int `json:"healthyPeers"` - RouteCount int `json:"routeCount"` - RouteMismatch bool `json:"routeMismatch"` + Timestamp time.Time `json:"timestamp"` + NodeInfo NodeInfo `json:"nodeInfo"` + HealthCheck *HealthCheckStatus `json:"healthCheck,omitempty"` + NodeErrors []NodeError `json:"nodeErrors,omitempty"` + FetchError string `json:"fetchError,omitempty"` + LastPushTime *time.Time `json:"lastPushTime,omitempty"` + StatusSource string `json:"statusSource,omitempty"` + NodePodInfo *NodePodInfo `json:"nodePodInfo,omitempty"` + PeerCount int `json:"peerCount"` + HealthyPeers int `json:"healthyPeers"` + RouteCount int `json:"routeCount"` + RouteMismatch bool `json:"routeMismatch"` + RouteMismatchCount int `json:"routeMismatchCount"` + UnhealthyPeerLinks int `json:"unhealthyPeerLinks"` + UsesIPIP bool `json:"usesIPIP"` } // NodeStatusResponse is the top-level status response for a node.