diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce0ebbe8..b8c0d8b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. - SDK (Rust) - Add `DZClient::from_context` and `GeoClient::from_context`, which build clients directly from a resolved RFC-20 `CliContext` instead of re-reading `~/.config/doublezero/cli/config.yml` and re-applying moniker conversion. The context already carries the fully resolved ledger RPC/WS URLs and program IDs, so these constructors consume them verbatim, making the context the single source of truth and removing the double-resolution the binary previously incurred. Keypair precedence is preserved exactly (CLI flag > `DOUBLEZERO_KEYPAIR` > stdin > context keypair path > default): the raw `--keypair` flag is passed as the highest-precedence source and the context keypair path is used only as the low-precedence fallback, so the env var still wins. The new constructors and their `doublezero-cli-core` dependency are gated behind a `cli-context` cargo feature so non-CLI SDK consumers (controlplane, telemetry, e2e) keep a dependency-light default build. `DZClient::new` / `GeoClient::new` are unchanged for callers that do not build a `CliContext` (e.g. `controlplane/doublezero-admin`). - CLI + - geolocation `user get`: Show probe code, rather than probe pubkey in target list. + - geolocation `probe get`: Show exchange code, rather than exchange pubkeys. - Honor the build-configured default environment (`Testnet` by default, `MainnetBeta` under the `default-mainnet-beta` feature) when neither `--env` nor a persisted `config.yml` selects one. The RFC-20 context-build previously fell back to `Environment::default()`, which is always `Devnet` regardless of the build, so a testnet build with no config silently targeted Devnet's ledger URLs and program IDs. The binary now resolves the fallback through the new `doublezero_sdk::default_environment()`, matching the legacy `DZClient::new` defaults (`default_program_id`, `ClientConfig::default`) which already key off the compiled-in environment ([#3810](https://github.com/malbeclabs/doublezero/pull/3810)) - Construct the serviceability and geolocation SDK clients in the `doublezero` binary via `DZClient::from_context` / `GeoClient::from_context`, replacing the legacy `DZClient::new(Option, ...)` bridge. The binary no longer round-trips the already-resolved `CliContext` values back through the SDK's config-file re-resolution. No user-facing command, flag, or output change. - Restore environment-moniker support for the `--program-id` and `--geo-program-id` global flags. The context-build resolved both flags with a raw `parse::()`, so a moniker (e.g. `--geo-program-id testnet`) failed to parse, was silently dropped, and the binary fell back to the environment default. Both flags now accept monikers in their full (`mainnet-beta`, `testnet`, `devnet`, `local`) and short (`m`, `t`, `d`, `l`) forms, resolving to the matching program ID; a literal pubkey still passes through. A value that is neither a known moniker nor a valid pubkey is now a hard error instead of being silently ignored. `convert_program_moniker` is broadened to cover all four environments (previously only `devnet`/`testnet`), matching `convert_geo_program_moniker`. diff --git a/crates/doublezero-geolocation-cli/src/probe/get.rs b/crates/doublezero-geolocation-cli/src/probe/get.rs index a13f2ad82..124e09a79 100644 --- a/crates/doublezero-geolocation-cli/src/probe/get.rs +++ b/crates/doublezero-geolocation-cli/src/probe/get.rs @@ -37,7 +37,10 @@ struct GeoProbeGetDisplay { #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + #[tabled(skip)] pub exchange: Pubkey, + #[tabled(rename = "exchange")] + pub exchange_code: String, pub public_ip: Ipv4Addr, pub port: u16, #[serde(serialize_with = "serialize_pubkey_vec_as_string_array")] @@ -64,6 +67,15 @@ impl GetGeoProbeCliCommand { pubkey_or_code: self.probe, })?; + let exchanges = client.list_exchanges().unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to list exchanges; showing exchange pubkey"); + Default::default() + }); + let exchange_code = exchanges + .get(&probe.exchange_pk) + .map(|ex| ex.code.clone()) + .unwrap_or_else(|| probe.exchange_pk.to_string()); + let parent_devices_display = format!( "[{}]", probe @@ -78,6 +90,7 @@ impl GetGeoProbeCliCommand { code: probe.code, owner: probe.owner, exchange: probe.exchange_pk, + exchange_code, public_ip: probe.public_ip, port: probe.location_offset_port, parent_devices: probe.parent_devices, @@ -112,9 +125,34 @@ mod tests { use crate::client::MockGeoCliCommand; use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; use doublezero_geolocation::state::{accounttype::AccountType, geo_probe::GeoProbe}; + use doublezero_sdk::{AccountType as SvcAccountType, Exchange, ExchangeStatus}; use mockall::predicate; use solana_sdk::pubkey::Pubkey; - use std::net::Ipv4Addr; + use std::{collections::HashMap, net::Ipv4Addr}; + + fn make_exchanges(exchange_pk: Pubkey, code: &str) -> HashMap { + let mut exchanges = HashMap::new(); + exchanges.insert( + exchange_pk, + Exchange { + account_type: SvcAccountType::Exchange, + owner: exchange_pk, + index: 0, + bump_seed: 0, + reference_count: 0, + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 0.0, + lng: 0.0, + bgp_community: 0, + unused: 0, + status: ExchangeStatus::Activated, + code: code.to_string(), + name: code.to_string(), + }, + ); + exchanges + } fn setup_client() -> (MockGeoCliCommand, Pubkey, Pubkey, Pubkey, Pubkey) { let client = MockGeoCliCommand::new(); @@ -161,6 +199,11 @@ mod tests { .expect_get_geo_probe() .returning(move |_| Err(eyre::eyre!("not found"))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -192,7 +235,9 @@ mod tests { assert!(has_row("account", &probe_pk.to_string())); assert!(has_row("code", "ams-probe-01")); assert!(has_row("owner", &owner_pk.to_string())); - assert!(has_row("exchange", &exchange_pk.to_string())); + // exchange column shows the code, not the pubkey + assert!(has_row("exchange", "ams")); + assert!(!output_str.contains(&exchange_pk.to_string())); assert!(has_row("public_ip", "10.0.0.1")); assert!(has_row("port", "8923")); assert!(has_row("signing_pubkey", &metrics_pk.to_string())); @@ -212,6 +257,11 @@ mod tests { })) .returning(move |_| Ok((probe_pk, probe.clone()))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -228,7 +278,9 @@ mod tests { assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); assert_eq!(json["owner"].as_str().unwrap(), owner_pk.to_string()); + // exchange retains the canonical pubkey; the code is a separate field assert_eq!(json["exchange"].as_str().unwrap(), exchange_pk.to_string()); + assert_eq!(json["exchange_code"].as_str().unwrap(), "ams"); assert_eq!(json["public_ip"].as_str().unwrap(), "10.0.0.1"); assert_eq!(json["port"].as_u64().unwrap(), 8923); let parents = json["parent_devices"].as_array().unwrap(); @@ -254,6 +306,11 @@ mod tests { })) .returning(move |_| Ok((probe_pk, probe.clone()))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -271,6 +328,71 @@ mod tests { let json: serde_json::Value = serde_json::from_str(trimmed).unwrap(); assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); + assert_eq!(json["exchange"].as_str().unwrap(), exchange_pk.to_string()); + assert_eq!(json["exchange_code"].as_str().unwrap(), "ams"); assert_eq!(json["parent_devices"].as_array().unwrap().len(), 1); } + + #[test] + fn test_cli_geo_probe_get_unknown_exchange_falls_back_to_pubkey() { + let (mut client, probe_pk, owner_pk, exchange_pk, metrics_pk) = setup_client(); + let probe = make_probe(owner_pk, exchange_pk, metrics_pk, vec![]); + + client + .expect_get_geo_probe() + .with(predicate::eq(GetGeoProbeCommand { + pubkey_or_code: probe_pk.to_string(), + })) + .returning(move |_| Ok((probe_pk, probe.clone()))); + + client + .expect_list_exchanges() + .returning(|| Ok(HashMap::new())); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeoProbeCliCommand { + probe: probe_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&exchange_pk.to_string())); + } + + #[test] + fn test_cli_geo_probe_get_exchange_list_error_falls_back_to_pubkey() { + let (mut client, probe_pk, owner_pk, exchange_pk, metrics_pk) = setup_client(); + let probe = make_probe(owner_pk, exchange_pk, metrics_pk, vec![]); + + client + .expect_get_geo_probe() + .with(predicate::eq(GetGeoProbeCommand { + pubkey_or_code: probe_pk.to_string(), + })) + .returning(move |_| Ok((probe_pk, probe.clone()))); + + // A failure to list exchanges must not abort the command; it falls back to the pubkey. + client + .expect_list_exchanges() + .returning(|| Err(eyre::eyre!("rpc error"))); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeoProbeCliCommand { + probe: probe_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&exchange_pk.to_string())); + } } diff --git a/crates/doublezero-geolocation-cli/src/user/get.rs b/crates/doublezero-geolocation-cli/src/user/get.rs index 57d4eb9fd..b39d0c656 100644 --- a/crates/doublezero-geolocation-cli/src/user/get.rs +++ b/crates/doublezero-geolocation-cli/src/user/get.rs @@ -5,7 +5,9 @@ use doublezero_geolocation::state::geolocation_user::{ GeoLocationTargetType, GeolocationBillingConfig, }; use doublezero_program_common::serializer; -use doublezero_sdk::geolocation::geolocation_user::get::GetGeolocationUserCommand; +use doublezero_sdk::geolocation::{ + geo_probe::list::ListGeoProbeCommand, geolocation_user::get::GetGeolocationUserCommand, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; use std::io::Write; @@ -50,8 +52,10 @@ struct TargetDisplay { pub port: u16, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub target_signing_pubkey: Pubkey, - #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] #[tabled(rename = "probe")] + pub probe: String, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + #[tabled(skip)] pub geoprobe_pk: Pubkey, } @@ -68,6 +72,13 @@ impl GetGeolocationUserCliCommand { pubkey_or_code: self.user, })?; + let probes = client + .list_geo_probes(ListGeoProbeCommand) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to list geo probes; showing probe pubkey"); + Default::default() + }); + let targets: Vec = user .targets .iter() @@ -78,11 +89,16 @@ impl GetGeolocationUserCliCommand { } GeoLocationTargetType::Inbound => ("-".to_string(), 0), }; + let probe = probes + .get(&t.geoprobe_pk) + .map(|p| p.code.clone()) + .unwrap_or_else(|| t.geoprobe_pk.to_string()); TargetDisplay { target_type: t.target_type.to_string(), ip, port, target_signing_pubkey: t.target_pk, + probe, geoprobe_pk: t.geoprobe_pk, } }) @@ -161,6 +177,7 @@ mod tests { use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; use doublezero_geolocation::state::{ accounttype::AccountType, + geo_probe::GeoProbe, geolocation_user::{ FlatPerEpochConfig, GeoLocationTargetType, GeolocationPaymentStatus, GeolocationTarget, GeolocationUser, GeolocationUserStatus, @@ -168,7 +185,27 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; - use std::net::Ipv4Addr; + use std::{collections::HashMap, net::Ipv4Addr}; + + fn make_probes(probe_pk: Pubkey, code: &str) -> HashMap { + let mut probes = HashMap::new(); + probes.insert( + probe_pk, + GeoProbe { + account_type: AccountType::GeoProbe, + owner: Pubkey::default(), + exchange_pk: Pubkey::default(), + public_ip: Ipv4Addr::new(10, 0, 0, 1), + location_offset_port: 8923, + code: code.to_string(), + parent_devices: vec![], + metrics_publisher_pk: Pubkey::default(), + reference_count: 0, + target_update_count: 0, + }, + ); + probes + } fn make_user(code: &str, targets: Vec) -> GeolocationUser { GeolocationUser { @@ -211,6 +248,11 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + let probes = make_probes(probe_pk, "ams-probe-01"); + client + .expect_list_geo_probes() + .returning(move |_| Ok(probes.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -236,6 +278,96 @@ mod tests { assert!(has_row("target_count", "1")); assert!(output_str.contains("Targets:")); assert!(output_str.contains("8.8.8.8")); + // probe column shows the code, not the pubkey + assert!(output_str.contains("ams-probe-01")); + assert!(!output_str.contains(&probe_pk.to_string())); + } + + #[test] + fn test_cli_geolocation_user_get_unknown_probe_falls_back_to_pubkey() { + let mut client = MockGeoCliCommand::new(); + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let probe_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + + let user = make_user( + "geo-user-01", + vec![GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk, + }], + ); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: user_pk.to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeolocationUserCliCommand { + user: user_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&probe_pk.to_string())); + } + + #[test] + fn test_cli_geolocation_user_get_probe_list_error_falls_back_to_pubkey() { + let mut client = MockGeoCliCommand::new(); + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let probe_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + + let user = make_user( + "geo-user-01", + vec![GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk, + }], + ); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: user_pk.to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + // A failure to list probes must not abort the command; it falls back to the pubkey. + client + .expect_list_geo_probes() + .returning(|_| Err(eyre::eyre!("rpc error"))); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeolocationUserCliCommand { + user: user_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&probe_pk.to_string())); } #[test] @@ -252,6 +384,10 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -288,6 +424,10 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -327,6 +467,11 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + let probes = make_probes(probe_pk, "ams-probe-01"); + client + .expect_list_geo_probes() + .returning(move |_| Ok(probes.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -347,5 +492,14 @@ mod tests { assert_eq!(json["target_count"].as_u64().unwrap(), 1); assert_eq!(json["targets"].as_array().unwrap().len(), 1); assert_eq!(json["targets"][0]["ip"].as_str().unwrap(), "8.8.8.8"); + // JSON carries the resolved probe code plus the canonical pubkey + assert_eq!( + json["targets"][0]["probe"].as_str().unwrap(), + "ams-probe-01" + ); + assert_eq!( + json["targets"][0]["geoprobe_pk"].as_str().unwrap(), + probe_pk.to_string() + ); } }