diff --git a/CHANGELOG.md b/CHANGELOG.md index e2035218b3..c82f642bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to this project will be documented in this file. ### Changes - CLI - - Extract `doublezero-daemon-cli` crate housing `DaemonClient` trait and the `status`, `enable`, `disable`, `latency`, and `routes` daemon verbs. The new crate owns all daemon HTTP interaction (Unix-socket client, response types) and is consumed by the `doublezero` binary. `check_daemon` binds `get_environment()` once per invocation instead of calling it per-check. + - Extract `doublezero-daemon-cli` crate housing `DaemonClient` trait and the `status`, `enable`, `disable`, `latency`, and `routes` daemon verbs. The new crate owns all daemon HTTP interaction (Unix-socket client, response types, shared output helpers) and is consumed by the `doublezero` binary. `check_daemon` binds `get_environment()` once per invocation instead of calling it per-check. - Fold `version`, `account`, `accounts`, `log`, and `subscribe` diagnostic verbs from the binary's top-level `Command` enum into `ServiceabilityCommand` per RFC-20. Each verb now takes `&CliContext` + generic `&C: CliCommand` + `&mut W` writer and is async. Add `--json` to `account`, `accounts`, and `log` (RFC-20 §Output). The binary-level `subscribe` override uses the real blocking `DZClient::subscribe` for live event streaming; the module crate's implementation falls back to a `get_all()` snapshot for testability. - Change `geolocation user update-payment` to `update-payment-status` for clarity. - geolocation `user get`: Show probe code, rather than probe pubkey in target list. diff --git a/Cargo.lock b/Cargo.lock index d4fddc25fe..412fb9aa9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,7 @@ dependencies = [ name = "doublezero-daemon-cli" version = "0.25.1" dependencies = [ + "backon", "chrono", "clap", "doublezero-cli-core", diff --git a/client/doublezero/src/cli/command.rs b/client/doublezero/src/cli/command.rs index d95aec8e27..4bd1f58fc9 100644 --- a/client/doublezero/src/cli/command.rs +++ b/client/doublezero/src/cli/command.rs @@ -8,7 +8,7 @@ use crate::{ cli::multicast::MulticastCliCommand, command::{ connect::ProvisioningCliCommand, disconnect::DecommissioningCliCommand, - latency::LatencyCliCommand, routes::RoutesCliCommand, status::StatusCliCommand, + latency::LatencyCliCommand, routes::RoutesCliCommand, }, }; @@ -32,8 +32,6 @@ pub enum Command { #[command(flatten)] Daemon(DaemonCommand), - /// Get the status of your service - Status(StatusCliCommand), /// Disconnect your server from the doublezero network Disconnect(DecommissioningCliCommand), /// Get device latencies diff --git a/client/doublezero/src/command/mod.rs b/client/doublezero/src/command/mod.rs index 68f9f19f16..44ede6b2ad 100644 --- a/client/doublezero/src/command/mod.rs +++ b/client/doublezero/src/command/mod.rs @@ -4,5 +4,4 @@ pub mod helpers; pub mod latency; pub mod multicast; pub mod routes; -pub mod status; pub mod util; diff --git a/client/doublezero/src/command/status.rs b/client/doublezero/src/command/status.rs deleted file mode 100644 index c8d4980d93..0000000000 --- a/client/doublezero/src/command/status.rs +++ /dev/null @@ -1,853 +0,0 @@ -use crate::{ - command::util, - requirements::check_doublezero, - servicecontroller::{ - DoubleZeroStatus, MulticastGroups, ServiceController, ServiceControllerImpl, StatusResponse, - }, -}; -use backon::{ExponentialBuilder, Retryable}; -use clap::Args; -use doublezero_serviceability_cli::{doublezerocommand::CliCommand, helpers::print_error}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use tabled::Tabled; - -#[derive(Args, Debug)] -pub struct StatusCliCommand { - /// Output as json - #[arg(long, default_value = "false")] - json: bool, -} - -#[derive(Tabled, Debug, Deserialize, Serialize)] -struct AppendedStatusResponse { - #[tabled(inline)] - response: StatusResponse, - #[tabled(rename = "Reconciler")] - reconciler_enabled: bool, - #[tabled(rename = "Tenant")] - tenant: String, - #[tabled(rename = "Current Device")] - current_device: String, - #[tabled(rename = "Lowest Latency Device")] - lowest_latency_device: String, - #[tabled(rename = "Metro")] - metro: String, - #[tabled(rename = "Network")] - network: String, - #[tabled(rename = "Multicast Groups")] - multicast_groups: String, -} - -fn format_multicast_groups(groups: &MulticastGroups) -> String { - let mut parts = Vec::new(); - for code in &groups.publisher { - parts.push(format!("P:{code}")); - } - for code in &groups.subscriber { - parts.push(format!("S:{code}")); - } - parts.join(",") -} - -impl StatusCliCommand { - pub async fn execute(&self, client: &dyn CliCommand) -> eyre::Result<()> { - let controller = ServiceControllerImpl::new(None); - check_doublezero(&controller, client, None).await?; - match self.command_impl(client, &controller).await { - Ok(responses) => util::show_output(responses, self.json)?, - Err(e) => { - print_error(e); - } - } - Ok(()) - } - - async fn command_impl( - &self, - client: &dyn CliCommand, - controller: &T, - ) -> eyre::Result> { - let backoff = ExponentialBuilder::new() - .with_max_times(3) - .with_min_delay(Duration::from_millis(500)) - .with_max_delay(Duration::from_secs(2)); - let v2_status = (|| controller.v2_status()).retry(backoff).await?; - - // When no services are running, synthesize a "disconnected" entry to match - // the legacy /status endpoint behavior. The QA agent and other tooling - // expect at least one entry in the status array. - if v2_status.services.is_empty() { - return Ok(vec![AppendedStatusResponse { - response: StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "disconnected".to_string(), - last_session_update: None, - }, - tunnel_name: None, - tunnel_src: None, - tunnel_dst: None, - doublezero_ip: None, - user_type: None, - }, - reconciler_enabled: v2_status.reconciler_enabled, - tenant: String::new(), - current_device: "N/A".to_string(), - lowest_latency_device: "N/A".to_string(), - metro: "N/A".to_string(), - network: if v2_status.network.is_empty() { - format!("{}", client.get_environment()) - } else { - v2_status.network.clone() - }, - multicast_groups: String::new(), - }]); - } - - let network = if v2_status.network.is_empty() { - format!("{}", client.get_environment()) - } else { - v2_status.network.clone() - }; - - let mut responses = Vec::with_capacity(v2_status.services.len()); - for svc in &v2_status.services { - let current_device = if svc.current_device.is_empty() { - "N/A".to_string() - } else { - svc.current_device.clone() - }; - let metro = if svc.metro.is_empty() { - "N/A".to_string() - } else { - svc.metro.clone() - }; - - // Apply display formatting for lowest_latency_device. - let lowest_latency_device = if svc.lowest_latency_device.is_empty() { - "N/A".to_string() - } else if self.json || svc.status.doublezero_status.session_status != "BGP Session Up" { - svc.lowest_latency_device.clone() - } else if svc.lowest_latency_device == current_device { - format!("✅ {}", svc.lowest_latency_device) - } else if current_device != "N/A" { - format!("⚠️ {}", svc.lowest_latency_device) - } else { - svc.lowest_latency_device.clone() - }; - - responses.push(AppendedStatusResponse { - response: svc.status.clone(), - reconciler_enabled: v2_status.reconciler_enabled, - current_device, - lowest_latency_device, - metro, - network: network.clone(), - tenant: svc.tenant.clone(), - multicast_groups: format_multicast_groups(&svc.multicast_groups), - }); - } - - Ok(responses) - } -} - -// NOTE: if the client is out of date, there is an error because the client warning will cause the json to be malformed. This was resolved in this PR (https://github.com/malbeclabs/doublezero/pull/2807) but the global monitor and maybe other things will break so these tests capture the expected format. The json response should be fixed sooner than later. -#[cfg(test)] -mod tests { - use super::*; - use crate::servicecontroller::{ - DoubleZeroStatus, MockServiceController, MulticastGroups, V2ServiceStatus, V2StatusResponse, - }; - use doublezero_serviceability_cli::doublezerocommand::MockCliCommand; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - - #[allow(clippy::too_many_arguments)] - fn make_v2_service( - session_status: &str, - tunnel_name: Option<&str>, - tunnel_src: Option<&str>, - tunnel_dst: Option<&str>, - doublezero_ip: Option<&str>, - user_type: Option<&str>, - current_device: &str, - lowest_latency_device: &str, - metro: &str, - tenant: &str, - ) -> V2ServiceStatus { - V2ServiceStatus { - status: StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: session_status.to_string(), - last_session_update: Some(1625247600), - }, - tunnel_name: tunnel_name.map(String::from), - tunnel_src: tunnel_src.map(String::from), - tunnel_dst: tunnel_dst.map(String::from), - doublezero_ip: doublezero_ip.map(String::from), - user_type: user_type.map(String::from), - }, - current_device: current_device.to_string(), - lowest_latency_device: lowest_latency_device.to_string(), - metro: metro.to_string(), - tenant: tenant.to_string(), - multicast_groups: MulticastGroups::default(), - } - } - - #[tokio::test] - async fn test_status_command_tunnel_up() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("tunnel_name"), - Some("1.2.3.4"), - Some("42.42.42.42"), - Some("1.2.3.4"), - Some("IBRL"), - "device1", - "device2", - "metro", - "", - )], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - let status_response = &result[0].response; - assert_eq!( - status_response.doublezero_status.session_status, - "BGP Session Up" - ); - assert_eq!(status_response.tunnel_name.as_deref(), Some("tunnel_name")); - assert_eq!(status_response.tunnel_src.as_deref(), Some("1.2.3.4")); - assert_eq!(status_response.tunnel_dst.as_deref(), Some("42.42.42.42")); - assert_eq!(status_response.doublezero_ip.as_deref(), Some("1.2.3.4")); - assert_eq!(status_response.user_type.as_deref(), Some("IBRL")); - assert_eq!(result[0].current_device, "device1"); - assert_eq!(result[0].lowest_latency_device, "device2"); - assert_eq!(result[0].metro, "metro"); - assert_eq!(result[0].network, "testnet"); - assert_eq!(result[0].tenant, ""); - } - - #[tokio::test] - async fn test_status_command_tunnel_down() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![V2ServiceStatus { - status: StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "BGP Session Down".to_string(), - last_session_update: None, - }, - tunnel_name: None, - tunnel_src: None, - tunnel_dst: None, - doublezero_ip: None, - user_type: None, - }, - current_device: String::new(), - lowest_latency_device: "device2".to_string(), - metro: String::new(), - tenant: String::new(), - multicast_groups: MulticastGroups::default(), - }], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - let status_response = &result[0].response; - assert_eq!( - status_response.doublezero_status.session_status, - "BGP Session Down" - ); - assert_eq!(status_response.tunnel_name.as_deref(), None); - assert_eq!(status_response.tunnel_src.as_deref(), None); - assert_eq!(status_response.tunnel_dst.as_deref(), None); - assert_eq!(status_response.doublezero_ip.as_deref(), None); - assert_eq!(status_response.user_type.as_deref(), None); - assert_eq!(result[0].current_device, "N/A"); - assert_eq!(result[0].lowest_latency_device, "device2"); - assert_eq!(result[0].metro, "N/A"); - assert_eq!(result[0].network, "testnet"); - assert_eq!(result[0].tenant, ""); - } - - #[tokio::test] - async fn test_status_command_enriched_from_daemon() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("tunnel_name"), - Some("20.20.20.20"), - Some("42.42.42.42"), - Some("1.2.3.4"), - Some("IBRL"), - "device1", - "device1", - "metro", - "", - )], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].current_device, "device1"); - assert_eq!(result[0].metro, "metro"); - } - - #[tokio::test] - async fn test_status_command_multicast_subscriber() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![V2ServiceStatus { - status: StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "BGP Session Up".to_string(), - last_session_update: Some(1625247600), - }, - tunnel_name: Some("doublezero1".to_string()), - tunnel_src: Some("10.10.10.10".to_string()), - tunnel_dst: Some("5.6.7.8".to_string()), - doublezero_ip: None, - user_type: Some("Multicast".to_string()), - }, - current_device: "device1".to_string(), - lowest_latency_device: "device1".to_string(), - metro: "metro".to_string(), - tenant: String::new(), - multicast_groups: MulticastGroups::default(), - }], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].current_device, "device1"); - assert_eq!(result[0].metro, "metro"); - assert_eq!(result[0].lowest_latency_device, "device1"); - } - - /// Test that validates the JSON output format for the status command. - /// This test catches breaking changes to the JSON API contract. - /// The JSON output is an array of AppendedStatusResponse objects. - #[test] - fn test_status_json_output_format() { - use crate::servicecontroller::DoubleZeroStatus; - - // Create a sample StatusResponse - let status_response = StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "BGP Session Up".to_string(), - last_session_update: Some(1625247600), - }, - tunnel_name: Some("doublezero1".to_string()), - tunnel_src: Some("10.0.0.1".to_string()), - tunnel_dst: Some("5.6.7.8".to_string()), - doublezero_ip: Some("10.1.2.3".to_string()), - user_type: Some("IBRL".to_string()), - }; - - // Create AppendedStatusResponse - let appended_response = AppendedStatusResponse { - response: status_response, - reconciler_enabled: true, - current_device: "device1".to_string(), - lowest_latency_device: "device1".to_string(), - metro: "amsterdam".to_string(), - network: "Testnet".to_string(), - tenant: "".to_string(), - multicast_groups: String::new(), - }; - - // JSON output is an array of status responses - let json_response = vec![appended_response]; - - // Serialize to JSON - let json_output = serde_json::to_value(&json_response).expect("Failed to serialize"); - - // Validate top-level structure is an array - assert!(json_output.is_array(), "Response should be an array"); - assert_eq!(json_output.as_array().unwrap().len(), 1); - - // Validate status entry fields - let status = &json_output.as_array().unwrap()[0]; - assert!(status.get("response").is_some(), "Missing 'response' field"); - assert!( - status.get("reconciler_enabled").is_some(), - "Missing 'reconciler_enabled' field" - ); - assert!( - status.get("current_device").is_some(), - "Missing 'current_device' field" - ); - assert!( - status.get("lowest_latency_device").is_some(), - "Missing 'lowest_latency_device' field" - ); - assert!(status.get("metro").is_some(), "Missing 'metro' field"); - assert!(status.get("network").is_some(), "Missing 'network' field"); - assert!(status.get("tenant").is_some(), "Missing 'tenant' field"); - assert!( - status.get("multicast_groups").is_some(), - "Missing 'multicast_groups' field" - ); - assert_eq!(status.get("multicast_groups").unwrap(), ""); - - // Validate response nested fields - let response = status.get("response").unwrap(); - assert!( - response.get("doublezero_status").is_some(), - "Missing 'doublezero_status' field" - ); - assert!( - response.get("tunnel_name").is_some(), - "Missing 'tunnel_name' field" - ); - assert!( - response.get("tunnel_src").is_some(), - "Missing 'tunnel_src' field" - ); - assert!( - response.get("tunnel_dst").is_some(), - "Missing 'tunnel_dst' field" - ); - assert!( - response.get("doublezero_ip").is_some(), - "Missing 'doublezero_ip' field" - ); - assert!( - response.get("user_type").is_some(), - "Missing 'user_type' field" - ); - - // Validate doublezero_status nested fields - let dz_status = response.get("doublezero_status").unwrap(); - assert!( - dz_status.get("session_status").is_some(), - "Missing 'session_status' field" - ); - assert!( - dz_status.get("last_session_update").is_some(), - "Missing 'last_session_update' field" - ); - - // Validate field values - assert_eq!(status.get("current_device").unwrap(), "device1"); - assert_eq!(status.get("lowest_latency_device").unwrap(), "device1"); - assert_eq!(status.get("metro").unwrap(), "amsterdam"); - assert_eq!(status.get("network").unwrap(), "Testnet"); - assert_eq!(response.get("tunnel_name").unwrap(), "doublezero1"); - assert_eq!(response.get("tunnel_src").unwrap(), "10.0.0.1"); - assert_eq!(response.get("tunnel_dst").unwrap(), "5.6.7.8"); - assert_eq!(response.get("doublezero_ip").unwrap(), "10.1.2.3"); - assert_eq!(response.get("user_type").unwrap(), "IBRL"); - assert_eq!(dz_status.get("session_status").unwrap(), "BGP Session Up"); - assert_eq!(dz_status.get("last_session_update").unwrap(), 1625247600); - } - - /// Test JSON output format with null/missing optional fields - #[test] - fn test_status_json_output_format_with_nulls() { - use crate::servicecontroller::DoubleZeroStatus; - - // Create a StatusResponse with None values (e.g., multicast subscriber without dz_ip) - let status_response = StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "PIM Adjacency Up".to_string(), - last_session_update: None, - }, - tunnel_name: Some("doublezero1".to_string()), - tunnel_src: Some("10.0.0.1".to_string()), - tunnel_dst: Some("5.6.7.8".to_string()), - doublezero_ip: None, // Multicast subscribers don't have dz_ip - user_type: Some("Multicast".to_string()), - }; - - let appended_response = AppendedStatusResponse { - response: status_response, - reconciler_enabled: true, - current_device: "device1".to_string(), - lowest_latency_device: "device1".to_string(), - metro: "amsterdam".to_string(), - network: "Testnet".to_string(), - tenant: "".to_string(), - multicast_groups: String::new(), - }; - - // JSON output is an array of status responses - let json_response = vec![appended_response]; - - let json_output = serde_json::to_value(&json_response).expect("Failed to serialize"); - - // Validate that null fields are properly serialized - let status = &json_output.as_array().unwrap()[0]; - let response = status.get("response").unwrap(); - - // doublezero_ip should be null - assert!( - response.get("doublezero_ip").is_some(), - "doublezero_ip field should exist" - ); - assert!( - response.get("doublezero_ip").unwrap().is_null(), - "doublezero_ip should be null" - ); - - // last_session_update should be null - let dz_status = response.get("doublezero_status").unwrap(); - assert!( - dz_status.get("last_session_update").unwrap().is_null(), - "last_session_update should be null" - ); - - // user_type should still be present - assert_eq!(response.get("user_type").unwrap(), "Multicast"); - - // multicast_groups should be present and empty - assert_eq!(status.get("multicast_groups").unwrap(), ""); - } - - #[tokio::test] - async fn test_status_reconciler_disabled() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: false, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("doublezero1"), - Some("1.2.3.4"), - Some("5.6.7.8"), - Some("10.0.0.1"), - Some("IBRL"), - "", - "", - "", - "", - )], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - assert!(!result[0].reconciler_enabled); - } - - #[tokio::test] - async fn test_status_empty_services_disconnected() { - let mut mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: false, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![], - }) - }); - mock_command - .expect_get_environment() - .return_const(doublezero_config::Environment::Testnet); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - // When no services are running, a synthetic "disconnected" entry is returned - assert_eq!(result.len(), 1); - assert_eq!( - result[0].response.doublezero_status.session_status, - "disconnected" - ); - assert!(!result[0].reconciler_enabled); - assert_eq!(result[0].current_device, "N/A"); - assert_eq!(result[0].network, "testnet"); - } - - /// Test that the lowest_latency_device display formatting works correctly. - /// When session is up, json=true returns raw device code. - #[tokio::test] - async fn test_status_lowest_latency_display_json() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("doublezero1"), - Some("1.2.3.4"), - Some("5.6.7.8"), - Some("10.0.0.1"), - Some("IBRL"), - "device1", - "device2", // different from current - "metro", - "", - )], - }) - }); - - // json=true: raw device code, no emoji - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await - .unwrap(); - assert_eq!(result[0].lowest_latency_device, "device2"); - - // json=false: should get warning emoji since lowest != current - let mut mock_controller2 = MockServiceController::new(); - mock_controller2.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("doublezero1"), - Some("1.2.3.4"), - Some("5.6.7.8"), - Some("10.0.0.1"), - Some("IBRL"), - "device1", - "device2", - "metro", - "", - )], - }) - }); - let result = StatusCliCommand { json: false } - .command_impl(&mock_command, &mock_controller2) - .await - .unwrap(); - assert_eq!(result[0].lowest_latency_device, "⚠️ device2"); - } - - #[tokio::test] - async fn test_status_command_multicast_groups_display() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - mock_controller.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![V2ServiceStatus { - status: StatusResponse { - doublezero_status: DoubleZeroStatus { - session_status: "BGP Session Up".to_string(), - last_session_update: Some(1625247600), - }, - tunnel_name: Some("doublezero1".to_string()), - tunnel_src: Some("10.10.10.10".to_string()), - tunnel_dst: Some("5.6.7.8".to_string()), - doublezero_ip: None, - user_type: Some("Multicast".to_string()), - }, - current_device: "device1".to_string(), - lowest_latency_device: "device1".to_string(), - metro: "metro".to_string(), - tenant: String::new(), - multicast_groups: MulticastGroups { - publisher: vec!["solana-lv".to_string()], - subscriber: vec!["solana-ams".to_string()], - }, - }], - }) - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].multicast_groups, "P:solana-lv,S:solana-ams"); - } - - #[test] - fn test_multicast_groups_serde_default() { - let json = r#"{ - "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, - "tunnel_name": null, "tunnel_src": null, "tunnel_dst": null, - "doublezero_ip": null, "user_type": "IBRL", - "current_device": "dz1", "lowest_latency_device": "dz1", - "metro": "ams", "tenant": "" - }"#; - let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); - assert!(svc.multicast_groups.publisher.is_empty()); - assert!(svc.multicast_groups.subscriber.is_empty()); - } - - #[test] - fn test_multicast_groups_serde_populated() { - let json = r#"{ - "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, - "tunnel_name": "doublezero1", "tunnel_src": "10.0.0.1", "tunnel_dst": "5.6.7.8", - "doublezero_ip": null, "user_type": "Multicast", - "current_device": "dz1", "lowest_latency_device": "dz1", - "metro": "ams", "tenant": "acme", - "multicast_groups": { - "publisher": ["solana-lv"], - "subscriber": ["solana-ams", "solana-fra"] - } - }"#; - let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); - assert_eq!(svc.multicast_groups.publisher, vec!["solana-lv"]); - assert_eq!( - svc.multicast_groups.subscriber, - vec!["solana-ams", "solana-fra"] - ); - } - - #[test] - fn test_multicast_groups_serde_empty_arrays() { - let json = r#"{ - "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, - "tunnel_name": null, "tunnel_src": null, "tunnel_dst": null, - "doublezero_ip": "10.0.0.1", "user_type": "IBRL", - "current_device": "dz1", "lowest_latency_device": "dz1", - "metro": "ams", "tenant": "", - "multicast_groups": {"publisher": [], "subscriber": []} - }"#; - let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); - assert!(svc.multicast_groups.publisher.is_empty()); - assert!(svc.multicast_groups.subscriber.is_empty()); - } - - #[test] - fn test_format_multicast_groups() { - assert_eq!(format_multicast_groups(&MulticastGroups::default()), ""); - assert_eq!( - format_multicast_groups(&MulticastGroups { - publisher: vec!["solana-lv".to_string()], - subscriber: vec![], - }), - "P:solana-lv" - ); - assert_eq!( - format_multicast_groups(&MulticastGroups { - publisher: vec![], - subscriber: vec!["solana-ams".to_string()], - }), - "S:solana-ams" - ); - assert_eq!( - format_multicast_groups(&MulticastGroups { - publisher: vec!["solana-lv".to_string()], - subscriber: vec!["solana-ams".to_string(), "solana-fra".to_string()], - }), - "P:solana-lv,S:solana-ams,S:solana-fra" - ); - } - - #[tokio::test] - async fn test_status_retries_transient_error() { - let mock_command = MockCliCommand::new(); - let mut mock_controller = MockServiceController::new(); - - let calls = Arc::new(AtomicUsize::new(0)); - let calls_clone = calls.clone(); - mock_controller.expect_v2_status().returning(move || { - if calls_clone.fetch_add(1, Ordering::SeqCst) < 2 { - Err(eyre::eyre!("Unable to connect to doublezero daemon: boom")) - } else { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: "testnet".to_string(), - services: vec![make_v2_service( - "BGP Session Up", - Some("doublezero1"), - Some("1.2.3.4"), - Some("5.6.7.8"), - Some("10.0.0.1"), - Some("IBRL"), - "device1", - "device1", - "metro", - "", - )], - }) - } - }); - - let result = StatusCliCommand { json: true } - .command_impl(&mock_command, &mock_controller) - .await; - - assert!(result.is_ok()); - assert_eq!(calls.load(Ordering::SeqCst), 3); - } -} diff --git a/client/doublezero/src/command/util.rs b/client/doublezero/src/command/util.rs index a4c1372da0..ee4836e104 100644 --- a/client/doublezero/src/command/util.rs +++ b/client/doublezero/src/command/util.rs @@ -1,3 +1,5 @@ +// TODO: remove once latency/routes migrate to doublezero-daemon-cli; +// the writer-based equivalent lives in doublezero_daemon_cli::helpers. use eyre::Result; use tabled::{settings::Style, Table, Tabled}; diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 09384cbc1a..62c5bcef3c 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -279,9 +279,9 @@ async fn main() -> eyre::Result<()> { // Skip version check for verbs that should always work even if the program is unavailable. let skip_version_check = matches!( &command, - Command::Status(_) - | Command::Daemon(DaemonCommand::Enable(_) | DaemonCommand::Disable(_)) - | Command::Completion(_) + Command::Daemon( + DaemonCommand::Enable(_) | DaemonCommand::Disable(_) | DaemonCommand::Status(_) + ) | Command::Completion(_) | Command::Serviceability( ServiceabilityCommand::Address(_) | ServiceabilityCommand::Balance(_) @@ -312,7 +312,6 @@ async fn main() -> eyre::Result<()> { cmd.execute(&ctx, &daemon, &ledger, &mut handle).await } - Command::Status(args) => args.execute(&client).await, Command::Disconnect(args) => args.execute(&client).await, Command::Latency(args) => args.execute(&client).await, Command::Routes(args) => args.execute(&client).await, diff --git a/crates/doublezero-daemon-cli/Cargo.toml b/crates/doublezero-daemon-cli/Cargo.toml index b3ebbd6dc9..60928a533f 100644 --- a/crates/doublezero-daemon-cli/Cargo.toml +++ b/crates/doublezero-daemon-cli/Cargo.toml @@ -13,6 +13,7 @@ repository.workspace = true name = "doublezero_daemon_cli" [dependencies] +backon.workspace = true chrono.workspace = true clap.workspace = true eyre.workspace = true diff --git a/crates/doublezero-daemon-cli/src/cli.rs b/crates/doublezero-daemon-cli/src/cli.rs index f36a674624..bdf213d714 100644 --- a/crates/doublezero-daemon-cli/src/cli.rs +++ b/crates/doublezero-daemon-cli/src/cli.rs @@ -8,7 +8,9 @@ use clap::Subcommand; use doublezero_cli_core::CliContext; use std::io::Write; -use crate::{client::DaemonClient, disable::Disable, enable::Enable, ledger::LedgerClient}; +use crate::{ + client::DaemonClient, disable::Disable, enable::Enable, ledger::LedgerClient, status::Status, +}; /// Daemon-control verbs hoisted to the binary's top level. /// @@ -19,6 +21,8 @@ pub enum DaemonCommand { Enable(Enable), /// Disable the reconciler (tear down tunnels and stop managing them) Disable(Disable), + /// Get the status of your service + Status(Status), } impl DaemonCommand { @@ -32,6 +36,7 @@ impl DaemonCommand { match self { Self::Enable(cmd) => cmd.execute(ctx, daemon, ledger, out).await, Self::Disable(cmd) => cmd.execute(ctx, daemon, ledger, out).await, + Self::Status(cmd) => cmd.execute(ctx, daemon, ledger, out).await, } } } diff --git a/crates/doublezero-daemon-cli/src/client.rs b/crates/doublezero-daemon-cli/src/client.rs index 1e51052916..f90ebba873 100644 --- a/crates/doublezero-daemon-cli/src/client.rs +++ b/crates/doublezero-daemon-cli/src/client.rs @@ -179,7 +179,7 @@ pub struct V2ServiceStatus { pub multicast_groups: MulticastGroups, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct V2StatusResponse { pub reconciler_enabled: bool, #[serde(default)] @@ -492,6 +492,56 @@ mod tests { assert!(groups.subscriber.is_empty()); } + #[test] + fn test_v2_service_status_serde_missing_multicast_groups() { + let json = r#"{ + "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, + "tunnel_name": null, "tunnel_src": null, "tunnel_dst": null, + "doublezero_ip": null, "user_type": "IBRL", + "current_device": "dz1", "lowest_latency_device": "dz1", + "metro": "ams", "tenant": "" + }"#; + let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); + assert!(svc.multicast_groups.publisher.is_empty()); + assert!(svc.multicast_groups.subscriber.is_empty()); + } + + #[test] + fn test_v2_service_status_serde_populated_multicast_groups() { + let json = r#"{ + "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, + "tunnel_name": "doublezero1", "tunnel_src": "10.0.0.1", "tunnel_dst": "5.6.7.8", + "doublezero_ip": null, "user_type": "Multicast", + "current_device": "dz1", "lowest_latency_device": "dz1", + "metro": "ams", "tenant": "acme", + "multicast_groups": { + "publisher": ["solana-lv"], + "subscriber": ["solana-ams", "solana-fra"] + } + }"#; + let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); + assert_eq!(svc.multicast_groups.publisher, vec!["solana-lv"]); + assert_eq!( + svc.multicast_groups.subscriber, + vec!["solana-ams", "solana-fra"] + ); + } + + #[test] + fn test_v2_service_status_serde_empty_multicast_arrays() { + let json = r#"{ + "doublezero_status": {"session_status": "BGP Session Up", "last_session_update": null}, + "tunnel_name": null, "tunnel_src": null, "tunnel_dst": null, + "doublezero_ip": "10.0.0.1", "user_type": "IBRL", + "current_device": "dz1", "lowest_latency_device": "dz1", + "metro": "ams", "tenant": "", + "multicast_groups": {"publisher": [], "subscriber": []} + }"#; + let svc: V2ServiceStatus = serde_json::from_str(json).unwrap(); + assert!(svc.multicast_groups.publisher.is_empty()); + assert!(svc.multicast_groups.subscriber.is_empty()); + } + #[test] fn test_daemon_client_impl_uses_explicit_socket_path() { let socket_path = diff --git a/crates/doublezero-daemon-cli/src/helpers.rs b/crates/doublezero-daemon-cli/src/helpers.rs new file mode 100644 index 0000000000..cdf877ca01 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/helpers.rs @@ -0,0 +1,21 @@ +//! Shared output helpers for daemon-control verbs. + +use std::io::Write; + +use tabled::{settings::Style, Table, Tabled}; + +/// Render a list of records as either pretty-printed JSON or a psql-style table. +pub fn show_output(data: Vec, is_output_json: bool, out: &mut W) -> eyre::Result<()> +where + T: serde::Serialize + Tabled, +{ + let output = if is_output_json { + serde_json::to_string_pretty(&data)? + } else { + Table::new(data) + .with(Style::psql().remove_horizontals()) + .to_string() + }; + writeln!(out, "{output}")?; + Ok(()) +} diff --git a/crates/doublezero-daemon-cli/src/lib.rs b/crates/doublezero-daemon-cli/src/lib.rs index aa737bf217..a1a81eb42a 100644 --- a/crates/doublezero-daemon-cli/src/lib.rs +++ b/crates/doublezero-daemon-cli/src/lib.rs @@ -7,8 +7,10 @@ pub mod cli; pub mod client; pub mod disable; pub mod enable; +pub mod helpers; pub mod ledger; mod requirements; +pub mod status; pub use cli::DaemonCommand; pub use client::{DaemonClient, DaemonClientImpl}; diff --git a/crates/doublezero-daemon-cli/src/status.rs b/crates/doublezero-daemon-cli/src/status.rs new file mode 100644 index 0000000000..9b4e444bcf --- /dev/null +++ b/crates/doublezero-daemon-cli/src/status.rs @@ -0,0 +1,720 @@ +//! `doublezero status` — show daemon service status. + +use std::io::Write; + +use backon::{ExponentialBuilder, Retryable}; +use clap::Args; +use doublezero_cli_core::CliContext; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tabled::Tabled; + +use crate::{ + client::{DaemonClient, DoubleZeroStatus, MulticastGroups, StatusResponse}, + helpers, + ledger::LedgerClient, + requirements::check_daemon, +}; + +/// Get the status of your service +#[derive(Args, Debug)] +pub struct Status { + /// Output as json + #[arg(long, default_value = "false")] + json: bool, +} + +#[derive(Tabled, Debug, Deserialize, Serialize)] +struct AppendedStatusResponse { + #[tabled(inline)] + response: StatusResponse, + #[tabled(rename = "Reconciler")] + reconciler_enabled: bool, + #[tabled(rename = "Tenant")] + tenant: String, + #[tabled(rename = "Current Device")] + current_device: String, + #[tabled(rename = "Lowest Latency Device")] + lowest_latency_device: String, + #[tabled(rename = "Metro")] + metro: String, + #[tabled(rename = "Network")] + network: String, + #[tabled(rename = "Multicast Groups")] + multicast_groups: String, +} + +fn format_multicast_groups(groups: &MulticastGroups) -> String { + let mut parts = Vec::new(); + for code in &groups.publisher { + parts.push(format!("P:{code}")); + } + for code in &groups.subscriber { + parts.push(format!("S:{code}")); + } + parts.join(",") +} + +impl Status { + pub async fn execute( + self, + _ctx: &CliContext, + daemon: &D, + ledger: &L, + out: &mut W, + ) -> eyre::Result<()> { + check_daemon(daemon, ledger).await?; + let responses = self.build_status(daemon, ledger).await?; + helpers::show_output(responses, self.json, out)?; + Ok(()) + } + + async fn build_status( + &self, + daemon: &D, + ledger: &L, + ) -> eyre::Result> { + let backoff = ExponentialBuilder::new() + .with_max_times(3) + .with_min_delay(Duration::from_millis(500)) + .with_max_delay(Duration::from_secs(2)); + let v2_status = (|| daemon.v2_status()).retry(backoff).await?; + + // When no services are running, synthesize a "disconnected" entry to match + // the legacy /status endpoint behavior. The QA agent and other tooling + // expect at least one entry in the status array. + if v2_status.services.is_empty() { + return Ok(vec![AppendedStatusResponse { + response: StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "disconnected".to_string(), + last_session_update: None, + }, + tunnel_name: None, + tunnel_src: None, + tunnel_dst: None, + doublezero_ip: None, + user_type: None, + }, + reconciler_enabled: v2_status.reconciler_enabled, + tenant: String::new(), + current_device: "N/A".to_string(), + lowest_latency_device: "N/A".to_string(), + metro: "N/A".to_string(), + network: if v2_status.network.is_empty() { + format!("{}", ledger.get_environment()) + } else { + v2_status.network.clone() + }, + multicast_groups: String::new(), + }]); + } + + let network = if v2_status.network.is_empty() { + format!("{}", ledger.get_environment()) + } else { + v2_status.network.clone() + }; + + let mut responses = Vec::with_capacity(v2_status.services.len()); + for svc in &v2_status.services { + let current_device = if svc.current_device.is_empty() { + "N/A".to_string() + } else { + svc.current_device.clone() + }; + let metro = if svc.metro.is_empty() { + "N/A".to_string() + } else { + svc.metro.clone() + }; + + // Apply display formatting for lowest_latency_device. + let lowest_latency_device = if svc.lowest_latency_device.is_empty() { + "N/A".to_string() + } else if self.json || svc.status.doublezero_status.session_status != "BGP Session Up" { + svc.lowest_latency_device.clone() + } else if svc.lowest_latency_device == current_device { + format!("✅ {}", svc.lowest_latency_device) + } else if current_device != "N/A" { + format!("⚠️ {}", svc.lowest_latency_device) + } else { + svc.lowest_latency_device.clone() + }; + + responses.push(AppendedStatusResponse { + response: svc.status.clone(), + reconciler_enabled: v2_status.reconciler_enabled, + current_device, + lowest_latency_device, + metro, + network: network.clone(), + tenant: svc.tenant.clone(), + multicast_groups: format_multicast_groups(&svc.multicast_groups), + }); + } + + Ok(responses) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + client::{MockDaemonClient, V2ServiceStatus, V2StatusResponse}, + ledger::MockLedgerClient, + }; + use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; + use doublezero_config::Environment; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + fn setup_passing_checks(daemon: &mut MockDaemonClient, ledger: &mut MockLedgerClient) { + daemon.expect_daemon_check().return_const(true); + daemon.expect_daemon_can_open().return_const(true); + daemon + .expect_get_env() + .returning(|| Ok(Environment::default())); + ledger + .expect_get_environment() + .returning(Environment::default); + } + + #[allow(clippy::too_many_arguments)] + fn make_v2_service( + session_status: &str, + tunnel_name: Option<&str>, + tunnel_src: Option<&str>, + tunnel_dst: Option<&str>, + doublezero_ip: Option<&str>, + user_type: Option<&str>, + current_device: &str, + lowest_latency_device: &str, + metro: &str, + tenant: &str, + ) -> V2ServiceStatus { + V2ServiceStatus { + status: StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: session_status.to_string(), + last_session_update: Some(1625247600), + }, + tunnel_name: tunnel_name.map(String::from), + tunnel_src: tunnel_src.map(String::from), + tunnel_dst: tunnel_dst.map(String::from), + doublezero_ip: doublezero_ip.map(String::from), + user_type: user_type.map(String::from), + }, + current_device: current_device.to_string(), + lowest_latency_device: lowest_latency_device.to_string(), + metro: metro.to_string(), + tenant: tenant.to_string(), + multicast_groups: MulticastGroups::default(), + } + } + + fn make_status_response(daemon: &mut MockDaemonClient, response: V2StatusResponse) { + daemon + .expect_v2_status() + .returning(move || Ok(response.clone())); + } + + #[test] + fn test_status_tunnel_up() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + make_status_response( + &mut daemon, + V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![make_v2_service( + "BGP Session Up", + Some("tunnel_name"), + Some("1.2.3.4"), + Some("42.42.42.42"), + Some("1.2.3.4"), + Some("IBRL"), + "device1", + "device2", + "metro", + "", + )], + }, + ); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].response.doublezero_status.session_status, + "BGP Session Up" + ); + assert_eq!( + parsed[0].response.tunnel_name.as_deref(), + Some("tunnel_name") + ); + assert_eq!(parsed[0].response.tunnel_src.as_deref(), Some("1.2.3.4")); + assert_eq!( + parsed[0].response.tunnel_dst.as_deref(), + Some("42.42.42.42") + ); + assert_eq!(parsed[0].response.doublezero_ip.as_deref(), Some("1.2.3.4")); + assert_eq!(parsed[0].response.user_type.as_deref(), Some("IBRL")); + assert_eq!(parsed[0].current_device, "device1"); + assert_eq!(parsed[0].lowest_latency_device, "device2"); + assert_eq!(parsed[0].metro, "metro"); + assert_eq!(parsed[0].network, "testnet"); + }); + } + + #[test] + fn test_status_tunnel_down() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + make_status_response( + &mut daemon, + V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![V2ServiceStatus { + status: StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "BGP Session Down".to_string(), + last_session_update: None, + }, + tunnel_name: None, + tunnel_src: None, + tunnel_dst: None, + doublezero_ip: None, + user_type: None, + }, + current_device: String::new(), + lowest_latency_device: "device2".to_string(), + metro: String::new(), + tenant: String::new(), + multicast_groups: MulticastGroups::default(), + }], + }, + ); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].response.doublezero_status.session_status, + "BGP Session Down" + ); + assert_eq!(parsed[0].current_device, "N/A"); + assert_eq!(parsed[0].lowest_latency_device, "device2"); + assert_eq!(parsed[0].metro, "N/A"); + assert_eq!(parsed[0].network, "testnet"); + }); + } + + #[test] + fn test_status_enriched_from_daemon() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + make_status_response( + &mut daemon, + V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![make_v2_service( + "BGP Session Up", + Some("tunnel_name"), + Some("20.20.20.20"), + Some("42.42.42.42"), + Some("1.2.3.4"), + Some("IBRL"), + "device1", + "device1", + "metro", + "", + )], + }, + ); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(parsed[0].current_device, "device1"); + assert_eq!(parsed[0].metro, "metro"); + }); + } + + #[test] + fn test_status_reconciler_disabled() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + make_status_response( + &mut daemon, + V2StatusResponse { + reconciler_enabled: false, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![make_v2_service( + "BGP Session Up", + Some("doublezero1"), + Some("1.2.3.4"), + Some("5.6.7.8"), + Some("10.0.0.1"), + Some("IBRL"), + "", + "", + "", + "", + )], + }, + ); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert!(!parsed[0].reconciler_enabled); + }); + } + + #[test] + fn test_status_empty_services_disconnected() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + make_status_response( + &mut daemon, + V2StatusResponse { + reconciler_enabled: false, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![], + }, + ); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].response.doublezero_status.session_status, + "disconnected" + ); + assert!(!parsed[0].reconciler_enabled); + assert_eq!(parsed[0].current_device, "N/A"); + assert_eq!(parsed[0].network, "testnet"); + }); + } + + #[test] + fn test_status_multicast_groups_display() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let mut ledger = MockLedgerClient::new(); + setup_passing_checks(&mut daemon, &mut ledger); + daemon.expect_v2_status().returning(|| { + Ok(V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![V2ServiceStatus { + status: StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "BGP Session Up".to_string(), + last_session_update: Some(1625247600), + }, + tunnel_name: Some("doublezero1".to_string()), + tunnel_src: Some("10.10.10.10".to_string()), + tunnel_dst: Some("5.6.7.8".to_string()), + doublezero_ip: None, + user_type: Some("Multicast".to_string()), + }, + current_device: "device1".to_string(), + lowest_latency_device: "device1".to_string(), + metro: "metro".to_string(), + tenant: String::new(), + multicast_groups: MulticastGroups { + publisher: vec!["solana-lv".to_string()], + subscriber: vec!["solana-ams".to_string()], + }, + }], + }) + }); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: true } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + let parsed: Vec = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(parsed[0].multicast_groups, "P:solana-lv,S:solana-ams"); + }); + } + + /// Test JSON output format contract — validates the exact field names and nesting. + #[test] + fn test_status_json_output_format() { + let status_response = StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "BGP Session Up".to_string(), + last_session_update: Some(1625247600), + }, + tunnel_name: Some("doublezero1".to_string()), + tunnel_src: Some("10.0.0.1".to_string()), + tunnel_dst: Some("5.6.7.8".to_string()), + doublezero_ip: Some("10.1.2.3".to_string()), + user_type: Some("IBRL".to_string()), + }; + + let appended_response = AppendedStatusResponse { + response: status_response, + reconciler_enabled: true, + current_device: "device1".to_string(), + lowest_latency_device: "device1".to_string(), + metro: "amsterdam".to_string(), + network: "Testnet".to_string(), + tenant: "".to_string(), + multicast_groups: String::new(), + }; + + let json_response = vec![appended_response]; + let json_output = serde_json::to_value(&json_response).expect("Failed to serialize"); + + assert!(json_output.is_array()); + assert_eq!(json_output.as_array().unwrap().len(), 1); + + let status = &json_output.as_array().unwrap()[0]; + assert!(status.get("response").is_some()); + assert!(status.get("reconciler_enabled").is_some()); + assert!(status.get("current_device").is_some()); + assert!(status.get("lowest_latency_device").is_some()); + assert!(status.get("metro").is_some()); + assert!(status.get("network").is_some()); + assert!(status.get("tenant").is_some()); + assert!(status.get("multicast_groups").is_some()); + + let response = status.get("response").unwrap(); + assert!(response.get("doublezero_status").is_some()); + assert!(response.get("tunnel_name").is_some()); + + let dz_status = response.get("doublezero_status").unwrap(); + assert_eq!(dz_status.get("session_status").unwrap(), "BGP Session Up"); + assert_eq!(dz_status.get("last_session_update").unwrap(), 1625247600); + assert_eq!(status.get("current_device").unwrap(), "device1"); + assert_eq!(status.get("metro").unwrap(), "amsterdam"); + } + + /// Test JSON output format with null/missing optional fields. + #[test] + fn test_status_json_output_format_with_nulls() { + let status_response = StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "PIM Adjacency Up".to_string(), + last_session_update: None, + }, + tunnel_name: Some("doublezero1".to_string()), + tunnel_src: Some("10.0.0.1".to_string()), + tunnel_dst: Some("5.6.7.8".to_string()), + doublezero_ip: None, + user_type: Some("Multicast".to_string()), + }; + + let appended_response = AppendedStatusResponse { + response: status_response, + reconciler_enabled: true, + current_device: "device1".to_string(), + lowest_latency_device: "device1".to_string(), + metro: "amsterdam".to_string(), + network: "Testnet".to_string(), + tenant: "".to_string(), + multicast_groups: String::new(), + }; + + let json_response = vec![appended_response]; + let json_output = serde_json::to_value(&json_response).expect("Failed to serialize"); + let status = &json_output.as_array().unwrap()[0]; + let response = status.get("response").unwrap(); + + assert!(response.get("doublezero_ip").unwrap().is_null()); + let dz_status = response.get("doublezero_status").unwrap(); + assert!(dz_status.get("last_session_update").unwrap().is_null()); + assert_eq!(response.get("user_type").unwrap(), "Multicast"); + } + + /// Test lowest_latency_device display formatting: json=true returns raw, json=false adds emoji. + #[test] + fn test_status_lowest_latency_display() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let ledger = MockLedgerClient::new(); + + daemon.expect_v2_status().returning(|| { + Ok(V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![make_v2_service( + "BGP Session Up", + Some("doublezero1"), + Some("1.2.3.4"), + Some("5.6.7.8"), + Some("10.0.0.1"), + Some("IBRL"), + "device1", + "device2", + "metro", + "", + )], + }) + }); + + // json=true: raw device code + let result = Status { json: true } + .build_status(&daemon, &ledger) + .await + .unwrap(); + assert_eq!(result[0].lowest_latency_device, "device2"); + + // json=false, different devices: warning emoji + let result = Status { json: false } + .build_status(&daemon, &ledger) + .await + .unwrap(); + assert_eq!(result[0].lowest_latency_device, "⚠️ device2"); + }); + } + + #[test] + fn test_format_multicast_groups() { + assert_eq!(format_multicast_groups(&MulticastGroups::default()), ""); + assert_eq!( + format_multicast_groups(&MulticastGroups { + publisher: vec!["solana-lv".to_string()], + subscriber: vec![], + }), + "P:solana-lv" + ); + assert_eq!( + format_multicast_groups(&MulticastGroups { + publisher: vec![], + subscriber: vec!["solana-ams".to_string()], + }), + "S:solana-ams" + ); + assert_eq!( + format_multicast_groups(&MulticastGroups { + publisher: vec!["solana-lv".to_string()], + subscriber: vec!["solana-ams".to_string(), "solana-fra".to_string()], + }), + "P:solana-lv,S:solana-ams,S:solana-fra" + ); + } + + #[test] + fn test_status_retries_transient_error() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + let ledger = MockLedgerClient::new(); + + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = calls.clone(); + daemon.expect_v2_status().returning(move || { + if calls_clone.fetch_add(1, Ordering::SeqCst) < 2 { + Err(eyre::eyre!("Unable to connect to doublezero daemon: boom")) + } else { + Ok(V2StatusResponse { + reconciler_enabled: true, + client_ip: String::new(), + network: "testnet".to_string(), + services: vec![make_v2_service( + "BGP Session Up", + Some("doublezero1"), + Some("1.2.3.4"), + Some("5.6.7.8"), + Some("10.0.0.1"), + Some("IBRL"), + "device1", + "device1", + "metro", + "", + )], + }) + } + }); + + let result = Status { json: true }.build_status(&daemon, &ledger).await; + + assert!(result.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 3); + }); + } + + #[test] + fn test_status_daemon_not_running() { + block_on(async { + let mut daemon = MockDaemonClient::new(); + daemon.expect_daemon_check().return_const(false); + let mut ledger = MockLedgerClient::new(); + ledger + .expect_get_environment() + .returning(Environment::default); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Status { json: false } + .execute(&ctx, &daemon, &ledger, &mut out) + .await; + + assert!(result.is_err()); + }); + } +}