From a123da40c510c0681ef69bf4dd1d03b51c08f0ee Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Tue, 9 Dec 2025 15:41:23 -0500 Subject: [PATCH] client/daemon: route liveness peer client version --- CHANGELOG.md | 1 + client/doublezero/src/routes.rs | 1 + client/doublezero/src/servicecontroller.rs | 2 + client/doublezerod/cmd/doublezerod/main.go | 9 +- client/doublezerod/internal/api/routes.go | 2 + .../doublezerod/internal/api/routes_test.go | 7 + client/doublezerod/internal/bgp/bgp_test.go | 19 ++- .../internal/liveness/faults_test.go | 2 + .../doublezerod/internal/liveness/manager.go | 34 +++- .../internal/liveness/manager_test.go | 50 +++--- .../doublezerod/internal/liveness/metrics.go | 19 ++- .../doublezerod/internal/liveness/packet.go | 41 +++-- .../internal/liveness/packet_test.go | 29 +++- .../doublezerod/internal/liveness/receiver.go | 2 +- .../internal/liveness/scheduler.go | 7 +- .../internal/liveness/scheduler_test.go | 80 ++++++++- .../doublezerod/internal/liveness/session.go | 20 ++- .../internal/liveness/session_test.go | 23 ++- .../doublezerod/internal/liveness/version.go | 111 +++++++++++++ .../internal/liveness/version_test.go | 156 ++++++++++++++++++ .../doublezerod/internal/runtime/run_test.go | 1 + 21 files changed, 541 insertions(+), 75 deletions(-) create mode 100644 client/doublezerod/internal/liveness/version.go create mode 100644 client/doublezerod/internal/liveness/version_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bcebfdcefb..31d918fc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Client - Route liveness treats peers that advertise passive mode as selectively passive; does not manage their routes directly. - Route liveness runs in passive mode for IBRL with allocated IP, if global passive mode is enabled. + - Advertise peer client version with route liveness control packets. ## [v0.8.0](https://github.com/malbeclabs/doublezero/compare/client/v0.7.1...client/v0.8.0) – 2025-12-02 diff --git a/client/doublezero/src/routes.rs b/client/doublezero/src/routes.rs index 8586f128c9..3f3c0073e7 100644 --- a/client/doublezero/src/routes.rs +++ b/client/doublezero/src/routes.rs @@ -36,6 +36,7 @@ mod tests { liveness_state: None, liveness_state_reason: None, peer_ip: peer_ip.to_string(), + peer_client_version: None, } } diff --git a/client/doublezero/src/servicecontroller.rs b/client/doublezero/src/servicecontroller.rs index a011d508ec..b0c2e00ec4 100644 --- a/client/doublezero/src/servicecontroller.rs +++ b/client/doublezero/src/servicecontroller.rs @@ -128,6 +128,8 @@ pub struct RouteRecord { pub liveness_state: Option, #[tabled(rename = "Liveness State Reason")] pub liveness_state_reason: Option, + #[tabled(rename = "Peer Client Version")] + pub peer_client_version: Option, } impl fmt::Display for RouteRecord { diff --git a/client/doublezerod/cmd/doublezerod/main.go b/client/doublezerod/cmd/doublezerod/main.go index add586755a..b8b82b32b7 100644 --- a/client/doublezerod/cmd/doublezerod/main.go +++ b/client/doublezerod/cmd/doublezerod/main.go @@ -53,7 +53,7 @@ var ( routeLivenessEnableActive = flag.Bool("route-liveness-enable-active", false, "enables route liveness in active mode (experimental)") // set by LDFLAGS - version = "dev" + version = "0.0.0-dev" commit = "none" date = "unknown" ) @@ -154,9 +154,10 @@ func main() { log = newLogger(slog.LevelDebug) } lmc = &liveness.ManagerConfig{ - Logger: log, - BindIP: defaultRouteLivenessBindIP, - Port: liveness.DefaultLivenessPort, + Logger: log, + BindIP: defaultRouteLivenessBindIP, + Port: liveness.DefaultLivenessPort, + ClientVersion: version, // If active mode is enabled, set passive mode to false. // The manager only knows about passive mode, with the negation of it being active mode. diff --git a/client/doublezerod/internal/api/routes.go b/client/doublezerod/internal/api/routes.go index 419c8608d6..f7dac4efae 100644 --- a/client/doublezerod/internal/api/routes.go +++ b/client/doublezerod/internal/api/routes.go @@ -34,6 +34,7 @@ type Route struct { LivenessStateReason string `json:"liveness_state_reason,omitempty"` LivenessExpectedKernelState string `json:"liveness_expected_kernel_state,omitempty"` LivenessPeerMode string `json:"liveness_peer_mode,omitempty"` + PeerClientVersion string `json:"peer_client_version,omitempty"` } type routeKey struct { @@ -131,6 +132,7 @@ func ServeRoutesHandler(nlr bgp.RouteReaderWriter, lm LivenessManager, db DBRead LivenessStateReason: stateReason, LivenessExpectedKernelState: sess.ExpectedKernelState.String(), LivenessPeerMode: sess.PeerAdvertisedMode.String(), + PeerClientVersion: sess.PeerClientVersion.String(), } break } diff --git a/client/doublezerod/internal/api/routes_test.go b/client/doublezerod/internal/api/routes_test.go index 4e0ec7bc1e..7fc4e085c0 100644 --- a/client/doublezerod/internal/api/routes_test.go +++ b/client/doublezerod/internal/api/routes_test.go @@ -238,6 +238,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_KernelOnly(t *testing.T) { require.Empty(t, rt.LivenessStateReason) require.Empty(t, rt.LivenessExpectedKernelState) require.Empty(t, rt.LivenessPeerMode) + require.Empty(t, rt.PeerClientVersion) } func TestClient_API_ServeRoutesHandler_WithLiveness_PresentInBoth(t *testing.T) { @@ -261,6 +262,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_PresentInBoth(t *testing.T) State: liveness.StateUp, ExpectedKernelState: liveness.KernelStatePresent, PeerAdvertisedMode: liveness.PeerModeActive, + PeerClientVersion: liveness.ClientVersion{Major: 1, Minor: 2, Patch: 3, Channel: liveness.VersionChannelStable}, } svc := &ProvisionRequest{ @@ -310,6 +312,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_PresentInBoth(t *testing.T) require.Empty(t, rt.LivenessStateReason) require.Equal(t, liveness.KernelStatePresent.String(), rt.LivenessExpectedKernelState) require.Equal(t, LivenessPeerModeActive.String(), rt.LivenessPeerMode) + require.Equal(t, sess.PeerClientVersion.String(), rt.PeerClientVersion) } func TestClient_API_ServeRoutesHandler_WithLiveness_AbsentInKernel(t *testing.T) { @@ -333,6 +336,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_AbsentInKernel(t *testing.T) State: liveness.StateDown, ExpectedKernelState: liveness.KernelStateAbsent, PeerAdvertisedMode: liveness.PeerModePassive, + PeerClientVersion: liveness.ClientVersion{Major: 1, Minor: 2, Patch: 3, Channel: liveness.VersionChannelStable}, } svc := &ProvisionRequest{ @@ -379,6 +383,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_AbsentInKernel(t *testing.T) require.Equal(t, liveness.KernelStateAbsent.String(), rt.LivenessExpectedKernelState) require.Equal(t, LivenessPeerModePassive.String(), rt.LivenessPeerMode) require.Equal(t, liveness.DownReasonNone.String(), rt.LivenessStateReason) + require.Equal(t, sess.PeerClientVersion.String(), rt.PeerClientVersion) } func TestClient_API_ServeRoutesHandler_WithLiveness_SetsLivenessStateReason(t *testing.T) { @@ -403,6 +408,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_SetsLivenessStateReason(t *t LastDownReason: liveness.DownReasonRemoteAdmin, ExpectedKernelState: liveness.KernelStateAbsent, PeerAdvertisedMode: liveness.PeerModePassive, + PeerClientVersion: liveness.ClientVersion{Major: 1, Minor: 2, Patch: 3, Channel: liveness.VersionChannelStable}, } svc := &ProvisionRequest{ @@ -452,6 +458,7 @@ func TestClient_API_ServeRoutesHandler_WithLiveness_SetsLivenessStateReason(t *t require.Equal(t, liveness.KernelStateAbsent.String(), rt.LivenessExpectedKernelState) require.Equal(t, LivenessPeerModePassive.String(), rt.LivenessPeerMode) require.Equal(t, liveness.DownReasonRemoteAdmin.String(), rt.LivenessStateReason) + require.Equal(t, sess.PeerClientVersion.String(), rt.PeerClientVersion) } func TestServeRoutesHandler_UsesDoubleZeroIP_NotTunnelSrc(t *testing.T) { diff --git a/client/doublezerod/internal/bgp/bgp_test.go b/client/doublezerod/internal/bgp/bgp_test.go index 24ce844c3c..c37805689b 100644 --- a/client/doublezerod/internal/bgp/bgp_test.go +++ b/client/doublezerod/internal/bgp/bgp_test.go @@ -118,15 +118,16 @@ func (p *dummyPlugin) handleUpdate(peer corebgp.PeerConfig, u []byte) *corebgp.N func TestBgpServer(t *testing.T) { nlr := &mockRouteReaderWriter{} lm, err := liveness.NewManager(t.Context(), &liveness.ManagerConfig{ - Logger: slog.Default(), - Netlinker: nlr, - BindIP: "127.0.0.1", - Port: 0, - TxMin: 100 * time.Millisecond, - RxMin: 100 * time.Millisecond, - DetectMult: 3, - MinTxFloor: 50 * time.Millisecond, - MaxTxCeil: 1 * time.Second, + Logger: slog.Default(), + Netlinker: nlr, + BindIP: "127.0.0.1", + Port: 0, + TxMin: 100 * time.Millisecond, + RxMin: 100 * time.Millisecond, + DetectMult: 3, + MinTxFloor: 50 * time.Millisecond, + MaxTxCeil: 1 * time.Second, + ClientVersion: "1.2.3-dev", }) require.NoError(t, err) t.Cleanup(func() { _ = lm.Close() }) diff --git a/client/doublezerod/internal/liveness/faults_test.go b/client/doublezerod/internal/liveness/faults_test.go index 1dbb5f07c9..45de7e5ece 100644 --- a/client/doublezerod/internal/liveness/faults_test.go +++ b/client/doublezerod/internal/liveness/faults_test.go @@ -698,6 +698,7 @@ func setupLivenessClients(t *testing.T, basePort int) ([]*testClient, *fakeUDPSw MinTxFloor: cfg.txMin, MaxTxCeil: cfg.txMin, BackoffMax: cfg.backoffMax, + ClientVersion: "1.2.3-dev", }) require.NoError(t, err) @@ -759,6 +760,7 @@ func setupMixedPassiveLivenessClients(t *testing.T, basePort int) ([]*testClient MinTxFloor: cfg.txMin, MaxTxCeil: cfg.txMin, BackoffMax: cfg.backoffMax, + ClientVersion: "1.2.3-dev", } switch i { diff --git a/client/doublezerod/internal/liveness/manager.go b/client/doublezerod/internal/liveness/manager.go index 1a423b9360..308fee46e1 100644 --- a/client/doublezerod/internal/liveness/manager.go +++ b/client/doublezerod/internal/liveness/manager.go @@ -86,6 +86,9 @@ type ManagerConfig struct { // liveness still tracks state and metrics, but will not install or // uninstall its routes in the kernel for that session. HonorPeerAdvertisedPassive bool + + // Client version to advertise to peers in control packets. + ClientVersion string } // Validate fills defaults and enforces constraints for ManagerConfig. @@ -142,6 +145,9 @@ func (c *ManagerConfig) Validate() error { if c.MaxEvents < 0 { return errors.New("maxEvents must be greater than 0") } + if c.ClientVersion == "" { + return errors.New("clientVersion is required") + } return nil } @@ -203,6 +209,11 @@ func NewManager(ctx context.Context, cfg *ManagerConfig) (*manager, error) { return nil, fmt.Errorf("error validating manager config: %v", err) } + clientVersion, err := ParseClientVersion(cfg.ClientVersion) + if err != nil { + return nil, fmt.Errorf("error parsing client version: %v", err) + } + udp := cfg.UDP if udp == nil { var err error @@ -249,7 +260,7 @@ func NewManager(ctx context.Context, cfg *ManagerConfig) (*manager, error) { // Wire up IO loops. m.recv = NewReceiver(m.log, m.udp, m.HandleRx, m.metrics) - m.sched = NewScheduler(m.log, m.udp, m.onSessionDown, m.cfg.MaxEvents, m.cfg.EnablePeerMetrics, m.metrics, m.cfg.PassiveMode) + m.sched = NewScheduler(m.log, m.udp, m.onSessionDown, m.cfg.MaxEvents, m.cfg.EnablePeerMetrics, m.metrics, m.cfg.PassiveMode, clientVersion) // Receiver goroutine: parses control packets and dispatches to HandleRx. m.wg.Add(1) @@ -737,7 +748,10 @@ func (m *manager) onSessionUp(sess *Session) { "peer", peer.String(), "route", snap.Route.String(), "convergence", convergence.String(), - "upSince", snap.UpSince.UTC().String()) + "upSince", snap.UpSince.UTC().String(), + "peerAdvertisedMode", snap.PeerAdvertisedMode.String(), + "peerClientVersion", snap.PeerClientVersion.String(), + ) } // onSessionDown withdraws the route if currently installed (unless PassiveMode @@ -779,7 +793,9 @@ func (m *manager) onSessionDown(sess *Session) { "peer", peer.String(), "routePresent", route != nil, "downSince", snap.DownSince.UTC().String(), - "downReason", snap.LastDownReason.String()) + "downReason", snap.LastDownReason.String(), + "peerClientVersion", snap.PeerClientVersion.String(), + ) return } @@ -788,7 +804,9 @@ func (m *manager) onSessionDown(sess *Session) { "peer", peer.String(), "route", snap.Route.String(), "downSince", snap.DownSince.UTC().String(), - "downReason", snap.LastDownReason.String()) + "downReason", snap.LastDownReason.String(), + "peerClientVersion", snap.PeerClientVersion.String(), + ) return } @@ -797,7 +815,9 @@ func (m *manager) onSessionDown(sess *Session) { "peer", peer.String(), "route", snap.Route.String(), "downSince", snap.DownSince.UTC().String(), - "downReason", snap.LastDownReason.String()) + "downReason", snap.LastDownReason.String(), + "peerClientVersion", snap.PeerClientVersion.String(), + ) return } @@ -816,7 +836,9 @@ func (m *manager) onSessionDown(sess *Session) { "route", snap.Route.String(), "convergence", convergence.String(), "downSince", snap.DownSince.UTC().String(), - "downReason", snap.LastDownReason.String()) + "downReason", snap.LastDownReason.String(), + "peerClientVersion", snap.PeerClientVersion.String(), + ) } // isPeerEffectivelyPassive returns true when this session should not have its diff --git a/client/doublezerod/internal/liveness/manager_test.go b/client/doublezerod/internal/liveness/manager_test.go index bd39146444..d29dcffa9f 100644 --- a/client/doublezerod/internal/liveness/manager_test.go +++ b/client/doublezerod/internal/liveness/manager_test.go @@ -16,47 +16,50 @@ func TestClient_Liveness_Manager_ConfigValidate(t *testing.T) { t.Parallel() log := newTestLogger(t) - err := (&ManagerConfig{Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1"}).Validate() + err := (&ManagerConfig{Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) - err = (&ManagerConfig{Logger: log, BindIP: "127.0.0.1"}).Validate() + err = (&ManagerConfig{Logger: log, BindIP: "127.0.0.1", ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) - err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: ""}).Validate() + err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "", ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) - err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", MinTxFloor: -1}).Validate() + err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", MinTxFloor: -1, ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) - err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", MaxTxCeil: -1}).Validate() + err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", MaxTxCeil: -1, ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) - err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", BackoffMax: -1}).Validate() + err = (&ManagerConfig{Logger: log, Netlinker: &MockRouteReaderWriter{}, BindIP: "127.0.0.1", BackoffMax: -1, ClientVersion: "1.2.3-dev"}).Validate() require.Error(t, err) err = (&ManagerConfig{ - Logger: log, - Netlinker: &MockRouteReaderWriter{}, - BindIP: "127.0.0.1", - TxMin: 100 * time.Millisecond, - RxMin: 100 * time.Millisecond, - DetectMult: 3, - MinTxFloor: 200 * time.Millisecond, - MaxTxCeil: 100 * time.Millisecond, - Port: -1, // invalid port + Logger: log, + Netlinker: &MockRouteReaderWriter{}, + BindIP: "127.0.0.1", + ClientVersion: "1.2.3-dev", + TxMin: 100 * time.Millisecond, + RxMin: 100 * time.Millisecond, + DetectMult: 3, + MinTxFloor: 200 * time.Millisecond, + MaxTxCeil: 100 * time.Millisecond, + Port: -1, // invalid port }).Validate() require.EqualError(t, err, "port must be greater than or equal to 0") cfg := &ManagerConfig{ - Logger: log, - Netlinker: &MockRouteReaderWriter{}, - BindIP: "127.0.0.1", - TxMin: 100 * time.Millisecond, - RxMin: 100 * time.Millisecond, - DetectMult: 3, - MinTxFloor: 50 * time.Millisecond, - MaxTxCeil: 1 * time.Second, + Logger: log, + Netlinker: &MockRouteReaderWriter{}, + ClientVersion: "1.2.3-dev", + BindIP: "127.0.0.1", + TxMin: 100 * time.Millisecond, + RxMin: 100 * time.Millisecond, + DetectMult: 3, + MinTxFloor: 50 * time.Millisecond, + MaxTxCeil: 1 * time.Second, } err = cfg.Validate() require.NoError(t, err) + require.Equal(t, "1.2.3-dev", cfg.ClientVersion) require.NotZero(t, cfg.MinTxFloor) require.NotZero(t, cfg.MaxTxCeil) require.NotZero(t, cfg.BackoffMax) @@ -1234,6 +1237,7 @@ func newTestManagerWithMetrics(t *testing.T, mutate func(*ManagerConfig)) (*mana MinTxFloor: 50 * time.Millisecond, MaxTxCeil: 1 * time.Second, BackoffMax: 1 * time.Second, + ClientVersion: "1.2.3-dev", } if mutate != nil { mutate(cfg) diff --git a/client/doublezerod/internal/liveness/metrics.go b/client/doublezerod/internal/liveness/metrics.go index 8c3e7c4d80..23b88f942d 100644 --- a/client/doublezerod/internal/liveness/metrics.go +++ b/client/doublezerod/internal/liveness/metrics.go @@ -8,14 +8,15 @@ import ( const ( // Labels. - LabelIface = "iface" - LabelLocalIP = "local_ip" - LabelPeerIP = "peer_ip" - LabelState = "state" - LabelStateTo = "state_to" - LabelStateFrom = "state_from" - LabelReason = "reason" - LabelOperation = "operation" + LabelIface = "iface" + LabelLocalIP = "local_ip" + LabelPeerIP = "peer_ip" + LabelState = "state" + LabelStateTo = "state_to" + LabelStateFrom = "state_from" + LabelReason = "reason" + LabelOperation = "operation" + LabelPeerClientVersion = "peer_client_version" ) type Metrics struct { @@ -154,7 +155,7 @@ func newMetrics() *Metrics { Name: "doublezero_liveness_control_packets_rx_total", Help: "Total control packets received.", }, - serviceLabels, + withServiceLabels(LabelPeerClientVersion), ), ControlPacketsRxInvalid: prometheus.NewCounterVec( prometheus.CounterOpts{ diff --git a/client/doublezerod/internal/liveness/packet.go b/client/doublezerod/internal/liveness/packet.go index 612c76bd82..0caf9d2e36 100644 --- a/client/doublezerod/internal/liveness/packet.go +++ b/client/doublezerod/internal/liveness/packet.go @@ -48,15 +48,16 @@ func (s State) String() string { // ControlPacket represents the wire format of a minimal BFD control packet. // Fields mirror RFC 5880 §4.1 in a compact form using microsecond units for timers. type ControlPacket struct { - Version uint8 // protocol version; expected to be 1 - State State // sender's current session state - DetectMult uint8 // detection multiplier (used by peer for detect timeout) - Length uint8 // total length, always 40 for this fixed-size implementation - LocalDiscr uint32 // sender's discriminator (unique session ID) - PeerDiscr uint32 // discriminator of the remote session (echo back) - DesiredMinTxUs uint32 // minimum TX interval desired by sender (microseconds) - RequiredMinRxUs uint32 // minimum RX interval the sender can handle (microseconds) - Flags uint8 // flags (e.g. passive mode) + Version uint8 // protocol version; expected to be 1 + State State // sender's current session state + DetectMult uint8 // detection multiplier (used by peer for detect timeout) + Length uint8 // total length, always 40 for this fixed-size implementation + LocalDiscr uint32 // sender's discriminator (unique session ID) + PeerDiscr uint32 // discriminator of the remote session (echo back) + DesiredMinTxUs uint32 // minimum TX interval desired by sender (microseconds) + RequiredMinRxUs uint32 // minimum RX interval the sender can handle (microseconds) + Flags uint8 // flags (e.g. passive mode) + ClientVersion ClientVersion // build/version info of the sender } // Marshal serializes a ControlPacket into its fixed 40-byte wire format. @@ -73,7 +74,11 @@ type ControlPacket struct { // 12–15: DesiredMinTxUs // 16–19: RequiredMinRxUs // 20: Flags -// 21–39: zero padding (unused / reserved) +// 21: ClientVersion.Major +// 22: ClientVersion.Minor +// 23: ClientVersion.Patch +// 24: ClientVersion.Channel +// 25–39: zero padding (unused / reserved) // // Only a subset of the full BFD header is implemented; authentication and // optional fields are omitted for simplicity. @@ -89,7 +94,13 @@ func (c *ControlPacket) Marshal() []byte { be.PutUint32(b[12:16], c.DesiredMinTxUs) be.PutUint32(b[16:20], c.RequiredMinRxUs) b[20] = c.Flags - // Remaining bytes [21:40] are reserved/padding → left zeroed + + // ClientVersion + b[21] = c.ClientVersion.Major + b[22] = c.ClientVersion.Minor + b[23] = c.ClientVersion.Patch + b[24] = uint8(c.ClientVersion.Channel) + // Remaining bytes [25:40] are reserved/padding → left zeroed return b } @@ -122,6 +133,14 @@ func UnmarshalControlPacket(b []byte) (*ControlPacket, error) { c.DesiredMinTxUs = rd(12) c.RequiredMinRxUs = rd(16) c.Flags = b[20] + + c.ClientVersion = ClientVersion{ + Major: b[21], + Minor: b[22], + Patch: b[23], + Channel: ClientVersionChannel(b[24]), + } + return c, nil } diff --git a/client/doublezerod/internal/liveness/packet_test.go b/client/doublezerod/internal/liveness/packet_test.go index 9f5e0b980a..e868c4cf12 100644 --- a/client/doublezerod/internal/liveness/packet_test.go +++ b/client/doublezerod/internal/liveness/packet_test.go @@ -18,6 +18,12 @@ func TestClient_Liveness_Packet_MarshalEncodesHeaderAndFields(t *testing.T) { PeerDiscr: 0x55667788, DesiredMinTxUs: 0x01020304, RequiredMinRxUs: 0x0A0B0C0D, + ClientVersion: ClientVersion{ + Major: 1, + Minor: 2, + Patch: 3, + Channel: VersionChannelDev, + }, } b := cp.Marshal() @@ -32,7 +38,14 @@ func TestClient_Liveness_Packet_MarshalEncodesHeaderAndFields(t *testing.T) { require.Equal(t, uint32(0x01020304), binary.BigEndian.Uint32(b[12:16])) require.Equal(t, uint32(0x0A0B0C0D), binary.BigEndian.Uint32(b[16:20])) - require.True(t, bytes.Equal(b[20:40], make([]byte, 20))) + // ClientVersion encoding + require.Equal(t, uint8(1), b[21]) + require.Equal(t, uint8(2), b[22]) + require.Equal(t, uint8(3), b[23]) + require.Equal(t, uint8(VersionChannelDev), b[24]) + + // Remaining padding bytes [25:39] are zero + require.True(t, bytes.Equal(b[25:40], make([]byte, 15))) } func TestClient_Liveness_Packet_UnmarshalRoundTrip(t *testing.T) { @@ -45,6 +58,12 @@ func TestClient_Liveness_Packet_UnmarshalRoundTrip(t *testing.T) { PeerDiscr: 2, DesiredMinTxUs: 3, RequiredMinRxUs: 4, + ClientVersion: ClientVersion{ + Major: 9, + Minor: 8, + Patch: 7, + Channel: VersionChannelBeta, + }, } b := orig.Marshal() got, err := UnmarshalControlPacket(b) @@ -58,6 +77,9 @@ func TestClient_Liveness_Packet_UnmarshalRoundTrip(t *testing.T) { require.Equal(t, uint32(2), got.PeerDiscr) require.Equal(t, uint32(3), got.DesiredMinTxUs) require.Equal(t, uint32(4), got.RequiredMinRxUs) + + // ClientVersion round-trip + require.Equal(t, orig.ClientVersion, got.ClientVersion) } func TestClient_Liveness_Packet_UnmarshalShort(t *testing.T) { @@ -106,7 +128,10 @@ func TestClient_Liveness_Packet_PaddingRemainsZero(t *testing.T) { t.Parallel() cp := &ControlPacket{Version: 3, State: StateDown, DetectMult: 5} b := cp.Marshal() - require.True(t, bytes.Equal(b[20:], make([]byte, 20))) + + // Flags (20) and ClientVersion (21–24) are part of the defined header; + // only the remaining padding bytes must be zero. + require.True(t, bytes.Equal(b[25:40], make([]byte, 15))) } func TestClient_Liveness_Packet_PassiveFlagRoundTrip(t *testing.T) { diff --git a/client/doublezerod/internal/liveness/receiver.go b/client/doublezerod/internal/liveness/receiver.go index 15789e026a..5affb74d6b 100644 --- a/client/doublezerod/internal/liveness/receiver.go +++ b/client/doublezerod/internal/liveness/receiver.go @@ -170,7 +170,7 @@ func (r *Receiver) Run(ctx context.Context) error { } localIP4 := localIP.To4().String() - r.metrics.ControlPacketsRX.WithLabelValues(ifname, localIP4).Inc() + r.metrics.ControlPacketsRX.WithLabelValues(ifname, localIP4, ctrl.ClientVersion.String()).Inc() // Populate the peer descriptor: identifies which local interface/IP // the packet arrived on and the remote endpoint that sent it. diff --git a/client/doublezerod/internal/liveness/scheduler.go b/client/doublezerod/internal/liveness/scheduler.go index 1add59a0ac..a692603cb8 100644 --- a/client/doublezerod/internal/liveness/scheduler.go +++ b/client/doublezerod/internal/liveness/scheduler.go @@ -147,12 +147,13 @@ type Scheduler struct { enablePeerMetrics bool metrics *Metrics - passiveMode bool + passiveMode bool + clientVersion ClientVersion } // NewScheduler constructs a Scheduler bound to a UDP transport and logger. // onSessionDown is called asynchronously whenever a session is detected as failed. -func NewScheduler(log *slog.Logger, udp UDPService, onSessionDown SessionDownFunc, maxEvents int, enablePeerMetrics bool, metrics *Metrics, passiveMode bool) *Scheduler { +func NewScheduler(log *slog.Logger, udp UDPService, onSessionDown SessionDownFunc, maxEvents int, enablePeerMetrics bool, metrics *Metrics, passiveMode bool, clientVersion ClientVersion) *Scheduler { eq := NewEventQueue() return &Scheduler{ log: log, @@ -164,6 +165,7 @@ func NewScheduler(log *slog.Logger, udp UDPService, onSessionDown SessionDownFun enablePeerMetrics: enablePeerMetrics, metrics: metrics, passiveMode: passiveMode, + clientVersion: clientVersion, } } @@ -365,6 +367,7 @@ func (s *Scheduler) doTX(ctx context.Context, sess *Session) { if s.passiveMode { pkt.SetPassive() } + pkt.ClientVersion = s.clientVersion bpkt := pkt.Marshal() peer := *sess.peer sess.mu.Unlock() diff --git a/client/doublezerod/internal/liveness/scheduler_test.go b/client/doublezerod/internal/liveness/scheduler_test.go index 8a6c58ec5c..fb9b2336e8 100644 --- a/client/doublezerod/internal/liveness/scheduler_test.go +++ b/client/doublezerod/internal/liveness/scheduler_test.go @@ -198,7 +198,7 @@ func TestClient_Liveness_Scheduler_Run_SendsAndReschedules(t *testing.T) { }() log := newTestLogger(t) - s := NewScheduler(log, w, func(*Session) {}, 0, false, newMetrics(), false) + s := NewScheduler(log, w, func(*Session) {}, 0, false, newMetrics(), false, mustParseClientVersion(t, "1.2.3-dev")) ctx, cancel := context.WithCancel(t.Context()) defer cancel() go func() { @@ -412,7 +412,7 @@ func TestClient_Liveness_Scheduler_Run_CullsStaleDetectAndClearsMarker(t *testin t.Parallel() log := newTestLogger(t) - s := NewScheduler(log, nil, func(*Session) {}, 0, false, newMetrics(), false) + s := NewScheduler(log, nil, func(*Session) {}, 0, false, newMetrics(), false, mustParseClientVersion(t, "1.2.3-dev")) ctx, cancel := context.WithCancel(t.Context()) defer cancel() @@ -525,7 +525,7 @@ func TestClient_Liveness_Scheduler_doTX_RespectsContextCancelOnWriteError(t *tes base := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) log := slog.New(&warnCountingHandler{inner: base, warnCount: &warns}) - s := NewScheduler(log, udp, func(*Session) {}, 0, false, newMetrics(), false) + s := NewScheduler(log, udp, func(*Session) {}, 0, false, newMetrics(), false, mustParseClientVersion(t, "1.2.3-dev")) // Disable throttling so every error can emit a warn if allowed by ctx. s.writeErrWarnEvery = 0 @@ -614,7 +614,7 @@ func TestClient_Liveness_Scheduler_doTX_SetsPassiveBitWhenPassiveModeEnabled(t * peerAddr := srv.LocalAddr().(*net.UDPAddr) // passiveMode=true here is the thing under test. - s := NewScheduler(newTestLogger(t), w, func(*Session) {}, 0, false, newMetrics(), true) + s := NewScheduler(newTestLogger(t), w, func(*Session) {}, 0, false, newMetrics(), true, mustParseClientVersion(t, "1.2.3-dev")) sess := &Session{ state: StateUp, @@ -651,6 +651,72 @@ func TestClient_Liveness_Scheduler_doTX_SetsPassiveBitWhenPassiveModeEnabled(t * require.True(t, cp.IsPassive(), "expected Passive bit set when scheduler.passiveMode is true") } +func TestClient_Liveness_Scheduler_doTX_SetsClientVersionOnPacket(t *testing.T) { + t.Parallel() + + srv, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + require.NoError(t, err) + defer srv.Close() + r, _ := NewUDPService(srv) + + cl, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + require.NoError(t, err) + defer cl.Close() + w, _ := NewUDPService(cl) + + peerAddr := srv.LocalAddr().(*net.UDPAddr) + + // This is the version we expect to see encoded in the ControlPacket. + clientVersion := mustParseClientVersion(t, "7.8.9-beta") + + // passiveMode=false here; what we care about is that ClientVersion is set on the packet. + s := NewScheduler( + newTestLogger(t), + w, + func(*Session) {}, + 0, + false, + newMetrics(), + false, + clientVersion, + ) + + sess := &Session{ + state: StateUp, + alive: true, + localTxMin: 20 * time.Millisecond, + localRxMin: 20 * time.Millisecond, + minTxFloor: 10 * time.Millisecond, + maxTxCeil: 200 * time.Millisecond, + detectMult: 3, + backoffMax: 200 * time.Millisecond, + backoffFactor: 1, + localDiscr: 123, + peerDiscr: 456, + peer: &Peer{ + Interface: "", + LocalIP: cl.LocalAddr().(*net.UDPAddr).IP.String(), + PeerIP: peerAddr.IP.String(), + }, + peerAddr: peerAddr, + } + + ctx := context.Background() + s.doTX(ctx, sess) + + // Read one packet and decode it. + buf := make([]byte, 128) + _ = srv.SetReadDeadline(time.Now().Add(time.Second)) + n, _, _, _, err := r.ReadFrom(buf) + require.NoError(t, err) + + cp, err := UnmarshalControlPacket(buf[:n]) + require.NoError(t, err) + + require.Equal(t, clientVersion, cp.ClientVersion, "expected ClientVersion on packet to match scheduler's configured version") + require.Equal(t, "7.8.9-beta", cp.ClientVersion.String(), "sanity check on encoded ClientVersion string") +} + type warnCountingHandler struct { inner slog.Handler warnCount *int32 @@ -680,3 +746,9 @@ func (h *warnCountingHandler) WithGroup(name string) slog.Handler { warnCount: h.warnCount, } } + +func mustParseClientVersion(t *testing.T, s string) ClientVersion { + v, err := ParseClientVersion(s) + require.NoError(t, err) + return v +} diff --git a/client/doublezerod/internal/liveness/session.go b/client/doublezerod/internal/liveness/session.go index 2936d9d3e3..8d036a9968 100644 --- a/client/doublezerod/internal/liveness/session.go +++ b/client/doublezerod/internal/liveness/session.go @@ -87,7 +87,8 @@ type Session struct { lastDownReason DownReason // reason for last transition to Down lastUpdated time.Time // time we last updated the session - peerAdvertisedMode PeerMode // peer advertised mode + peerAdvertisedMode PeerMode // peer advertised mode + peerClientVersion ClientVersion // peer advertised client version // detectMult scales the detection timeout relative to the receive interval; // it defines how many consecutive RX intervals may elapse without traffic @@ -260,6 +261,9 @@ func (s *Session) HandleRx(now time.Time, ctrl *ControlPacket) (changed bool) { s.peerAdvertisedMode = PeerModeActive } + // Learn client version advertised by the peer. + s.peerClientVersion = ctrl.ClientVersion + prev := s.state // If peer is in AdminDown, treat this as an intentional shutdown. @@ -426,15 +430,24 @@ type SessionSnapshot struct { NextDetectScheduled time.Time LastUpdated time.Time PeerAdvertisedMode PeerMode + PeerClientVersion ClientVersion ExpectedKernelState KernelState } func (s *Session) Snapshot() SessionSnapshot { s.mu.Lock() defer s.mu.Unlock() + var peer Peer + if s.peer != nil { + peer = *s.peer + } + var route Route + if s.route != nil { + route = *s.route + } return SessionSnapshot{ - Peer: *s.peer, - Route: *s.route, + Peer: peer, + Route: route, State: s.state, LocalDiscr: s.localDiscr, PeerDiscr: s.peerDiscr, @@ -447,5 +460,6 @@ func (s *Session) Snapshot() SessionSnapshot { NextDetectScheduled: s.nextDetectScheduled, LastUpdated: s.lastUpdated, PeerAdvertisedMode: s.peerAdvertisedMode, + PeerClientVersion: s.peerClientVersion, } } diff --git a/client/doublezerod/internal/liveness/session_test.go b/client/doublezerod/internal/liveness/session_test.go index ddf3caf643..571754fcf0 100644 --- a/client/doublezerod/internal/liveness/session_test.go +++ b/client/doublezerod/internal/liveness/session_test.go @@ -444,6 +444,19 @@ func TestClient_Liveness_Session_HandleRx_TracksCurrentPeerAdvertisedPassive(t * now := time.Now() + cvPassive := ClientVersion{ + Major: 1, + Minor: 2, + Patch: 3, + Channel: VersionChannelAlpha, + } + cvActive := ClientVersion{ + Major: 2, + Minor: 0, + Patch: 0, + Channel: VersionChannelDev, + } + // First packet: peer advertises passive. cpPassive := &ControlPacket{ Version: 1, @@ -454,13 +467,17 @@ func TestClient_Liveness_Session_HandleRx_TracksCurrentPeerAdvertisedPassive(t * PeerDiscr: 0, DesiredMinTxUs: 30_000, RequiredMinRxUs: 40_000, + ClientVersion: cvPassive, } cpPassive.SetPassive() _ = s.HandleRx(now, cpPassive) require.Equal(t, PeerModePassive, s.peerAdvertisedMode, "should reflect current passive=on") - // Second packet: same session, but peer no longer advertises passive. + snap := s.Snapshot() + require.Equal(t, cvPassive, snap.PeerClientVersion, "snapshot should reflect peer's advertised client version (passive packet)") + + // Second packet: same session, but peer no longer advertises passive and changes version. cpActive := &ControlPacket{ Version: 1, State: StateInit, @@ -470,8 +487,12 @@ func TestClient_Liveness_Session_HandleRx_TracksCurrentPeerAdvertisedPassive(t * PeerDiscr: 42, // echo our localDiscr DesiredMinTxUs: 20_000, RequiredMinRxUs: 20_000, + ClientVersion: cvActive, } _ = s.HandleRx(now.Add(10*time.Millisecond), cpActive) require.Equal(t, PeerModeActive, s.peerAdvertisedMode, "peerAdvertisedMode should reflect current (no passive flag)") + + snap = s.Snapshot() + require.Equal(t, cvActive, snap.PeerClientVersion, "snapshot should reflect latest advertised client version") } diff --git a/client/doublezerod/internal/liveness/version.go b/client/doublezerod/internal/liveness/version.go new file mode 100644 index 0000000000..2fc22d5083 --- /dev/null +++ b/client/doublezerod/internal/liveness/version.go @@ -0,0 +1,111 @@ +package liveness + +import ( + "fmt" + "strconv" + "strings" +) + +// ClientVersionChannel represents a coarse "release channel" for the build. +// This is encoded as a single byte in the ControlPacket wire format. +// Only small, stable changes should be made here. +type ClientVersionChannel uint8 + +const ( + VersionChannelStable ClientVersionChannel = iota + VersionChannelAlpha + VersionChannelBeta + VersionChannelRC + VersionChannelDev + VersionChannelOther +) + +// String returns the semver-compatible suffix for the channel. +// Example: Alpha → "-alpha". +func (ch ClientVersionChannel) String() string { + switch ch { + case VersionChannelStable: + return "" + case VersionChannelAlpha: + return "-alpha" + case VersionChannelBeta: + return "-beta" + case VersionChannelRC: + return "-rc" + case VersionChannelDev: + return "-dev" + case VersionChannelOther: + return "-other" + default: + return fmt.Sprintf("-unknown(%d)", uint8(ch)) + } +} + +// ClientVersion encodes the semver-like build version of the peer. +// +// IMPORTANT: This structure is serialized directly into the 40-byte +// ControlPacket wire format (bytes 21–24). Any change to its size, +// ordering, or meaning **changes the on-wire protocol**. Update with care. +type ClientVersion struct { + Major uint8 // Semver major version + Minor uint8 // Semver minor version + Patch uint8 // Semver patch version + Channel ClientVersionChannel // Pre-release / dev channel indicator +} + +// String returns a semver-like string (e.g. "1.2.3-dev"). +func (v ClientVersion) String() string { + return fmt.Sprintf("%d.%d.%d%s", + v.Major, v.Minor, v.Patch, v.Channel.String()) +} + +// ParseClientVersion parses a semver-like string (e.g. "1.2.3-dev") into a ClientVersion. +func ParseClientVersion(s string) (ClientVersion, error) { + var v ClientVersion + + if s == "" { + return v, fmt.Errorf("empty version string") + } + + parts := strings.SplitN(s, "-", 2) + + nums := strings.Split(parts[0], ".") + if len(nums) != 3 { + return v, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s) + } + + maj, err := strconv.Atoi(nums[0]) + if err != nil || maj < 0 || maj > 255 { + return v, fmt.Errorf("invalid major version in %q", s) + } + min, err := strconv.Atoi(nums[1]) + if err != nil || min < 0 || min > 255 { + return v, fmt.Errorf("invalid minor version in %q", s) + } + pat, err := strconv.Atoi(nums[2]) + if err != nil || pat < 0 || pat > 255 { + return v, fmt.Errorf("invalid patch version in %q", s) + } + + ch := VersionChannelStable + if len(parts) == 2 { + switch parts[1] { + case "alpha": + ch = VersionChannelAlpha + case "beta": + ch = VersionChannelBeta + case "rc": + ch = VersionChannelRC + case "dev": + ch = VersionChannelDev + default: + ch = VersionChannelOther + } + } + + v.Major = uint8(maj) + v.Minor = uint8(min) + v.Patch = uint8(pat) + v.Channel = ch + return v, nil +} diff --git a/client/doublezerod/internal/liveness/version_test.go b/client/doublezerod/internal/liveness/version_test.go new file mode 100644 index 0000000000..25047c77b2 --- /dev/null +++ b/client/doublezerod/internal/liveness/version_test.go @@ -0,0 +1,156 @@ +package liveness + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClient_Liveness_ClientVersionChannel_String_KnownValues(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + ch ClientVersionChannel + want string + }{ + {"stable", VersionChannelStable, ""}, + {"alpha", VersionChannelAlpha, "-alpha"}, + {"beta", VersionChannelBeta, "-beta"}, + {"rc", VersionChannelRC, "-rc"}, + {"dev", VersionChannelDev, "-dev"}, + {"other", VersionChannelOther, "-other"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.ch.String()) + }) + } +} + +func TestClient_Liveness_ClientVersionChannel_String_UnknownValue(t *testing.T) { + t.Parallel() + + ch := ClientVersionChannel(250) + require.Equal(t, "-unknown(250)", ch.String()) +} + +func TestClient_Liveness_ClientVersion_String(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + v ClientVersion + want string + }{ + {"stable", ClientVersion{Major: 1, Minor: 2, Patch: 3, Channel: VersionChannelStable}, "1.2.3"}, + {"alpha", ClientVersion{Major: 1, Minor: 0, Patch: 0, Channel: VersionChannelAlpha}, "1.0.0-alpha"}, + {"beta", ClientVersion{Major: 0, Minor: 1, Patch: 5, Channel: VersionChannelBeta}, "0.1.5-beta"}, + {"rc", ClientVersion{Major: 9, Minor: 9, Patch: 9, Channel: VersionChannelRC}, "9.9.9-rc"}, + {"dev", ClientVersion{Major: 2, Minor: 3, Patch: 4, Channel: VersionChannelDev}, "2.3.4-dev"}, + {"other", ClientVersion{Major: 3, Minor: 4, Patch: 5, Channel: VersionChannelOther}, "3.4.5-other"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.v.String()) + }) + } +} + +func TestClient_Liveness_ParseClientVersion_Success(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + maj uint8 + min uint8 + patch uint8 + channel ClientVersionChannel + }{ + {"stable", "1.2.3", 1, 2, 3, VersionChannelStable}, + {"alpha", "1.2.3-alpha", 1, 2, 3, VersionChannelAlpha}, + {"beta", "1.2.3-beta", 1, 2, 3, VersionChannelBeta}, + {"rc", "1.2.3-rc", 1, 2, 3, VersionChannelRC}, + {"dev", "1.2.3-dev", 1, 2, 3, VersionChannelDev}, + {"otherSuffix", "1.2.3-foo", 1, 2, 3, VersionChannelOther}, + {"otherWithHyphen", "1.2.3-foo-bar", 1, 2, 3, VersionChannelOther}, + {"maxValues", "255.255.255-dev", 255, 255, 255, VersionChannelDev}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ParseClientVersion(tc.in) + require.NoError(t, err) + require.Equal(t, tc.maj, got.Major) + require.Equal(t, tc.min, got.Minor) + require.Equal(t, tc.patch, got.Patch) + require.Equal(t, tc.channel, got.Channel) + }) + } +} + +func TestClient_Liveness_ParseClientVersion_Invalid(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + err string + }{ + {"empty", "", "empty version string"}, + {"tooFewParts", "1.2", `invalid version "1.2": expected MAJOR.MINOR.PATCH`}, + {"tooManyParts", "1.2.3.4", `invalid version "1.2.3.4": expected MAJOR.MINOR.PATCH`}, + {"nonNumericMajor", "x.2.3", `invalid major version in "x.2.3"`}, + {"nonNumericMinor", "1.x.3", `invalid minor version in "1.x.3"`}, + {"nonNumericPatch", "1.2.x", `invalid patch version in "1.2.x"`}, + {"majorOutOfRange", "256.0.0", `invalid major version in "256.0.0"`}, + {"minorOutOfRange", "1.256.0", `invalid minor version in "1.256.0"`}, + {"patchOutOfRange", "1.2.256", `invalid patch version in "1.2.256"`}, + {"negativeMajor", "-1.2.3", `invalid version "-1.2.3": expected MAJOR.MINOR.PATCH`}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := ParseClientVersion(tc.in) + require.Error(t, err) + require.EqualError(t, err, tc.err) + }) + } +} + +func TestClient_Liveness_ClientVersion_RoundTrip_StringAndParse(t *testing.T) { + t.Parallel() + + cases := []ClientVersion{ + {Major: 0, Minor: 0, Patch: 1, Channel: VersionChannelStable}, + {Major: 1, Minor: 2, Patch: 3, Channel: VersionChannelAlpha}, + {Major: 4, Minor: 5, Patch: 6, Channel: VersionChannelBeta}, + {Major: 7, Minor: 8, Patch: 9, Channel: VersionChannelRC}, + {Major: 10, Minor: 11, Patch: 12, Channel: VersionChannelDev}, + {Major: 13, Minor: 14, Patch: 15, Channel: VersionChannelOther}, + } + + for i, v := range cases { + v := v + t.Run(v.String(), func(t *testing.T) { + t.Parallel() + s := v.String() + got, err := ParseClientVersion(s) + require.NoError(t, err, "case %d", i) + // For VersionChannelOther, ParseClientVersion only understands "-other", + // which matches ClientVersionChannel.String(), so equality is fine. + require.Equal(t, v, got) + }) + } +} diff --git a/client/doublezerod/internal/runtime/run_test.go b/client/doublezerod/internal/runtime/run_test.go index 69310e0f96..34394c5d70 100644 --- a/client/doublezerod/internal/runtime/run_test.go +++ b/client/doublezerod/internal/runtime/run_test.go @@ -2557,5 +2557,6 @@ func newTestLivenessManagerConfig() *liveness.ManagerConfig { MinTxFloor: 50 * time.Millisecond, MaxTxCeil: 1 * time.Second, MetricsRegistry: prometheus.NewRegistry(), + ClientVersion: "1.2.3-dev", } }