From a193e492d17dd6b79ff1c61956fec2b460003244 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Thu, 4 Jun 2026 16:20:22 +0000 Subject: [PATCH 1/3] daemon-cli: move enable/disable verbs into doublezero-daemon-cli crate Move the enable and disable verbs from the binary (client/doublezero/src/command/) into the doublezero-daemon-cli module crate per RFC-20. This is PR 2 of 8 in the daemon-cli extraction stack. - Create enable.rs, disable.rs, and requirements.rs in daemon-cli crate - Replace println!/eprintln! with writeln!(out, ...)/tracing::warn! - Add Enable and Disable variants to DaemonCommand enum - Update binary dispatch with LedgerAdapter bridging CliCommand to LedgerClient - Add per-verb unit tests with MockDaemonClient + MockLedgerClient - Delete original verb files from the binary --- Cargo.lock | 1 + client/doublezero/src/cli/command.rs | 21 +- client/doublezero/src/command/disable.rs | 118 ---------- client/doublezero/src/command/enable.rs | 111 ---------- client/doublezero/src/command/mod.rs | 2 - client/doublezero/src/main.rs | 37 +++- crates/doublezero-daemon-cli/Cargo.toml | 1 + crates/doublezero-daemon-cli/src/cli.rs | 22 +- crates/doublezero-daemon-cli/src/disable.rs | 206 ++++++++++++++++++ crates/doublezero-daemon-cli/src/enable.rs | 156 +++++++++++++ crates/doublezero-daemon-cli/src/lib.rs | 3 + .../doublezero-daemon-cli/src/requirements.rs | 35 +++ 12 files changed, 461 insertions(+), 252 deletions(-) delete mode 100644 client/doublezero/src/command/disable.rs delete mode 100644 client/doublezero/src/command/enable.rs create mode 100644 crates/doublezero-daemon-cli/src/disable.rs create mode 100644 crates/doublezero-daemon-cli/src/enable.rs create mode 100644 crates/doublezero-daemon-cli/src/requirements.rs diff --git a/Cargo.lock b/Cargo.lock index 4d3081c971..d4fddc25fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ "serde_json", "tabled", "tokio", + "tracing", ] [[package]] diff --git a/client/doublezero/src/cli/command.rs b/client/doublezero/src/cli/command.rs index 102ee7ff22..d95aec8e27 100644 --- a/client/doublezero/src/cli/command.rs +++ b/client/doublezero/src/cli/command.rs @@ -1,22 +1,23 @@ use clap::{Args, Subcommand}; use clap_complete::Shell; +use doublezero_daemon_cli::DaemonCommand; use doublezero_geolocation_cli::GeolocationArgs; use doublezero_serviceability_cli::cli::ServiceabilityCommand; use crate::{ cli::multicast::MulticastCliCommand, command::{ - connect::ProvisioningCliCommand, disable::DisableCliCommand, - disconnect::DecommissioningCliCommand, enable::EnableCliCommand, + connect::ProvisioningCliCommand, disconnect::DecommissioningCliCommand, latency::LatencyCliCommand, routes::RoutesCliCommand, status::StatusCliCommand, }, }; /// Top-level command tree for the unified `doublezero` binary. /// -/// Per RFC-20 §Module contract item 2, the serviceability verbs live in -/// `doublezero_serviceability_cli::cli::ServiceabilityCommand` and are hoisted -/// to the top level here via `#[command(flatten)]`. The binary retains the +/// Per RFC-20 §Module contract item 2, module-crate verbs are hoisted to the +/// top level via `#[command(flatten)]`: serviceability verbs from +/// `doublezero_serviceability_cli`, daemon-control verbs (`enable`, `disable`) +/// from `doublezero_daemon_cli`. The binary retains the not-yet-migrated /// daemon-control verbs, the `doublezero-geolocation-cli` module crate's /// geolocation subtree (via `GeolocationArgs`), the binary-only `Completion` /// generator, and `Multicast` (whose `Subscribe`/`Unsubscribe`/`Publish`/ @@ -25,10 +26,12 @@ use crate::{ pub enum Command { /// Connect your server to a doublezero device Connect(ProvisioningCliCommand), - /// Enable the reconciler (start managing tunnels) - Enable(EnableCliCommand), - /// Disable the reconciler (tear down tunnels and stop managing them) - Disable(DisableCliCommand), + + /// Daemon-control verbs migrated to `doublezero-daemon-cli` (RFC-20). + /// Hoisted to top-level via `#[command(flatten)]`. + #[command(flatten)] + Daemon(DaemonCommand), + /// Get the status of your service Status(StatusCliCommand), /// Disconnect your server from the doublezero network diff --git a/client/doublezero/src/command/disable.rs b/client/doublezero/src/command/disable.rs deleted file mode 100644 index 6c7b130a50..0000000000 --- a/client/doublezero/src/command/disable.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::{ - requirements::check_doublezero, - servicecontroller::{ServiceController, ServiceControllerImpl}, -}; -use clap::Args; -use doublezero_serviceability_cli::doublezerocommand::CliCommand; - -#[derive(Args, Debug)] -pub struct DisableCliCommand {} - -impl DisableCliCommand { - pub async fn execute(&self, client: &dyn CliCommand) -> eyre::Result<()> { - let controller = ServiceControllerImpl::new(None); - self.execute_with_service_controller(client, &controller) - .await - } - - pub async fn execute_with_service_controller( - &self, - client: &dyn CliCommand, - controller: &T, - ) -> eyre::Result<()> { - check_doublezero(controller, client, None).await?; - - if let Ok(v2) = controller.v2_status().await { - if !v2.reconciler_enabled { - println!("Reconciler already disabled"); - return Ok(()); - } - // Check if any services are active and warn the user. - let has_active = v2.services.iter().any(|s| s.status.user_type.is_some()); - if has_active { - println!("Active tunnel(s) will be torn down"); - } - } - - controller.disable().await?; - println!("Reconciler disabled"); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::servicecontroller::{MockServiceController, V2StatusResponse}; - use doublezero_config::Environment; - use doublezero_serviceability_cli::tests::utils::create_test_client; - - fn setup_mock() -> MockServiceController { - let mut mock = MockServiceController::new(); - mock.expect_service_controller_check().return_const(true); - mock.expect_service_controller_can_open().return_const(true); - mock.expect_get_env() - .returning_st(|| Ok(Environment::default())); - mock.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: true, - client_ip: String::new(), - network: String::new(), - services: vec![], - }) - }); - mock - } - - fn setup_client() -> doublezero_serviceability_cli::doublezerocommand::MockCliCommand { - let mut client = create_test_client(); - client - .expect_get_environment() - .returning_st(Environment::default); - client - } - - #[tokio::test] - async fn test_disable_command_success() { - let mut mock = setup_mock(); - mock.expect_disable().returning(|| Ok(())); - - let client = setup_client(); - let command = DisableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_disable_command_daemon_error() { - let mut mock = setup_mock(); - mock.expect_disable() - .returning(|| Err(eyre::eyre!("connection refused"))); - - let client = setup_client(); - let command = DisableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("connection refused")); - } - - #[tokio::test] - async fn test_disable_command_daemon_not_running() { - let mut mock = MockServiceController::new(); - mock.expect_service_controller_check().return_const(false); - - let client = setup_client(); - let command = DisableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_err()); - } -} diff --git a/client/doublezero/src/command/enable.rs b/client/doublezero/src/command/enable.rs deleted file mode 100644 index 845ec3af3c..0000000000 --- a/client/doublezero/src/command/enable.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crate::{ - requirements::check_doublezero, - servicecontroller::{ServiceController, ServiceControllerImpl}, -}; -use clap::Args; -use doublezero_serviceability_cli::doublezerocommand::CliCommand; - -#[derive(Args, Debug)] -pub struct EnableCliCommand {} - -impl EnableCliCommand { - pub async fn execute(&self, client: &dyn CliCommand) -> eyre::Result<()> { - let controller = ServiceControllerImpl::new(None); - self.execute_with_service_controller(client, &controller) - .await - } - - pub async fn execute_with_service_controller( - &self, - client: &dyn CliCommand, - controller: &T, - ) -> eyre::Result<()> { - check_doublezero(controller, client, None).await?; - if let Ok(v2) = controller.v2_status().await { - if v2.reconciler_enabled { - println!("Reconciler already enabled"); - return Ok(()); - } - } - controller.enable().await?; - println!("Reconciler enabled"); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::servicecontroller::{MockServiceController, V2StatusResponse}; - use doublezero_config::Environment; - use doublezero_serviceability_cli::tests::utils::create_test_client; - - fn setup_mock() -> MockServiceController { - let mut mock = MockServiceController::new(); - mock.expect_service_controller_check().return_const(true); - mock.expect_service_controller_can_open().return_const(true); - mock.expect_get_env() - .returning_st(|| Ok(Environment::default())); - mock.expect_v2_status().returning(|| { - Ok(V2StatusResponse { - reconciler_enabled: false, - client_ip: String::new(), - network: String::new(), - services: vec![], - }) - }); - mock - } - - fn setup_client() -> doublezero_serviceability_cli::doublezerocommand::MockCliCommand { - let mut client = create_test_client(); - client - .expect_get_environment() - .returning_st(Environment::default); - client - } - - #[tokio::test] - async fn test_enable_command_success() { - let mut mock = setup_mock(); - mock.expect_enable().returning(|| Ok(())); - - let client = setup_client(); - let command = EnableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_enable_command_daemon_error() { - let mut mock = setup_mock(); - mock.expect_enable() - .returning(|| Err(eyre::eyre!("connection refused"))); - - let client = setup_client(); - let command = EnableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("connection refused")); - } - - #[tokio::test] - async fn test_enable_command_daemon_not_running() { - let mut mock = MockServiceController::new(); - mock.expect_service_controller_check().return_const(false); - - let client = setup_client(); - let command = EnableCliCommand {}; - let result = command - .execute_with_service_controller(&client, &mock) - .await; - assert!(result.is_err()); - } -} diff --git a/client/doublezero/src/command/mod.rs b/client/doublezero/src/command/mod.rs index 2476355d4f..68f9f19f16 100644 --- a/client/doublezero/src/command/mod.rs +++ b/client/doublezero/src/command/mod.rs @@ -1,7 +1,5 @@ pub mod connect; -pub mod disable; pub mod disconnect; -pub mod enable; pub mod helpers; pub mod latency; pub mod multicast; diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 11621c3296..09384cbc1a 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -10,6 +10,7 @@ mod requirements; mod servicecontroller; use crate::cli::{command::Command, multicast::MulticastCommands}; use doublezero_cli_core::LogLevel; +use doublezero_daemon_cli::{DaemonClientImpl, DaemonCommand}; use doublezero_geolocation_cli::GeoCliCommandImpl; use doublezero_sdk::{ convert_geo_program_moniker, convert_program_moniker, geolocation::client::GeoClient, DZClient, @@ -17,10 +18,24 @@ use doublezero_sdk::{ }; use doublezero_serviceability::pda::get_globalstate_pda; use doublezero_serviceability_cli::{ - checkversion::check_version, cli::ServiceabilityCommand, doublezerocommand::CliCommandImpl, + checkversion::check_version, + cli::ServiceabilityCommand, + doublezerocommand::{CliCommand, CliCommandImpl}, }; use servicecontroller::ServiceControllerImpl; +/// Adapter bridging the binary's `CliCommand` to the daemon-cli crate's +/// `LedgerClient` trait. +struct LedgerAdapter { + env: Environment, +} + +impl doublezero_daemon_cli::LedgerClient for LedgerAdapter { + fn get_environment(&self) -> Environment { + self.env + } +} + #[derive(Parser, Debug)] #[command(term_width = 0)] #[command(name = "DoubleZero")] @@ -136,6 +151,7 @@ async fn main() -> eyre::Result<()> { if let Some(sock_file) = &app.sock_file { ServiceControllerImpl::set_global_socket_path(sock_file.to_string_lossy()); + DaemonClientImpl::set_global_socket_path(sock_file.to_string_lossy()); } if let Some(keypair) = &app.keypair { @@ -264,8 +280,7 @@ async fn main() -> eyre::Result<()> { let skip_version_check = matches!( &command, Command::Status(_) - | Command::Enable(_) - | Command::Disable(_) + | Command::Daemon(DaemonCommand::Enable(_) | DaemonCommand::Disable(_)) | Command::Completion(_) | Command::Serviceability( ServiceabilityCommand::Address(_) @@ -283,8 +298,20 @@ async fn main() -> eyre::Result<()> { let res = match command { // Daemon-control verbs (binary-local) Command::Connect(args) => args.execute(&client).await, - Command::Enable(args) => args.execute(&client).await, - Command::Disable(args) => args.execute(&client).await, + + // Daemon-control verbs migrated to doublezero-daemon-cli (RFC-20) + Command::Daemon(cmd) => { + let daemon = DaemonClientImpl::new( + ctx.daemon_socket_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + ); + let ledger = LedgerAdapter { + env: client.get_environment(), + }; + 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, diff --git a/crates/doublezero-daemon-cli/Cargo.toml b/crates/doublezero-daemon-cli/Cargo.toml index 414380fe55..b3ebbd6dc9 100644 --- a/crates/doublezero-daemon-cli/Cargo.toml +++ b/crates/doublezero-daemon-cli/Cargo.toml @@ -25,6 +25,7 @@ mockall.workspace = true serde.workspace = true serde_json.workspace = true tabled.workspace = true +tracing.workspace = true doublezero-cli-core.workspace = true doublezero-config.workspace = true diff --git a/crates/doublezero-daemon-cli/src/cli.rs b/crates/doublezero-daemon-cli/src/cli.rs index 4b40f8b8a4..f36a674624 100644 --- a/crates/doublezero-daemon-cli/src/cli.rs +++ b/crates/doublezero-daemon-cli/src/cli.rs @@ -8,22 +8,30 @@ use clap::Subcommand; use doublezero_cli_core::CliContext; use std::io::Write; -use crate::{client::DaemonClient, ledger::LedgerClient}; +use crate::{client::DaemonClient, disable::Disable, enable::Enable, ledger::LedgerClient}; /// Daemon-control verbs hoisted to the binary's top level. /// /// Populated incrementally as verbs migrate from the binary into this crate. #[derive(Subcommand, Debug)] -pub enum DaemonCommand {} +pub enum DaemonCommand { + /// Enable the reconciler (start managing tunnels) + Enable(Enable), + /// Disable the reconciler (tear down tunnels and stop managing them) + Disable(Disable), +} impl DaemonCommand { pub async fn execute( self, - _ctx: &CliContext, - _daemon: &D, - _ledger: &L, - _out: &mut W, + ctx: &CliContext, + daemon: &D, + ledger: &L, + out: &mut W, ) -> eyre::Result<()> { - match self {} + match self { + Self::Enable(cmd) => cmd.execute(ctx, daemon, ledger, out).await, + Self::Disable(cmd) => cmd.execute(ctx, daemon, ledger, out).await, + } } } diff --git a/crates/doublezero-daemon-cli/src/disable.rs b/crates/doublezero-daemon-cli/src/disable.rs new file mode 100644 index 0000000000..dc08e5e84b --- /dev/null +++ b/crates/doublezero-daemon-cli/src/disable.rs @@ -0,0 +1,206 @@ +//! `doublezero disable` — stop the reconciler. + +use std::io::Write; + +use clap::Args; +use doublezero_cli_core::CliContext; + +use crate::{client::DaemonClient, ledger::LedgerClient, requirements::check_daemon}; + +/// Disable the reconciler (tear down tunnels and stop managing them) +#[derive(Args, Debug)] +pub struct Disable {} + +impl Disable { + pub async fn execute( + self, + _ctx: &CliContext, + daemon: &D, + ledger: &L, + out: &mut W, + ) -> eyre::Result<()> { + check_daemon(daemon, ledger).await?; + + if let Ok(v2) = daemon.v2_status().await { + if !v2.reconciler_enabled { + writeln!(out, "Reconciler already disabled")?; + return Ok(()); + } + let has_active = v2.services.iter().any(|s| s.status.user_type.is_some()); + if has_active { + writeln!(out, "Active tunnel(s) will be torn down")?; + } + } + + daemon.disable().await?; + writeln!(out, "Reconciler disabled")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + client::{ + DoubleZeroStatus, MockDaemonClient, StatusResponse, V2ServiceStatus, V2StatusResponse, + }, + ledger::MockLedgerClient, + }; + use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; + use doublezero_config::Environment; + + 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); + } + + #[test] + fn test_disable_success() { + 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: String::new(), + services: vec![], + }) + }); + daemon.expect_disable().returning(|| Ok(())); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Disable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + assert_eq!(output, "Reconciler disabled\n"); + }); + } + + #[test] + fn test_disable_already_disabled() { + 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: false, + client_ip: String::new(), + network: String::new(), + services: vec![], + }) + }); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Disable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + assert_eq!(output, "Reconciler already disabled\n"); + }); + } + + #[test] + fn test_disable_with_active_tunnels() { + 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: String::new(), + services: vec![V2ServiceStatus { + status: StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "BGP Session Up".to_string(), + last_session_update: None, + }, + tunnel_name: Some("doublezero1".to_string()), + tunnel_src: None, + tunnel_dst: None, + doublezero_ip: None, + user_type: Some("IBRL".to_string()), + }, + current_device: String::new(), + lowest_latency_device: String::new(), + metro: String::new(), + tenant: String::new(), + multicast_groups: Default::default(), + }], + }) + }); + daemon.expect_disable().returning(|| Ok(())); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Disable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + assert!(output.contains("Active tunnel(s) will be torn down")); + assert!(output.contains("Reconciler disabled")); + }); + } + + #[test] + fn test_disable_daemon_error() { + 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: String::new(), + services: vec![], + }) + }); + daemon + .expect_disable() + .returning(|| Err(eyre::eyre!("connection refused"))); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Disable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("connection refused")); + }); + } + + #[test] + fn test_disable_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 = Disable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_err()); + }); + } +} diff --git a/crates/doublezero-daemon-cli/src/enable.rs b/crates/doublezero-daemon-cli/src/enable.rs new file mode 100644 index 0000000000..7c0ec003c9 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/enable.rs @@ -0,0 +1,156 @@ +//! `doublezero enable` — start the reconciler. + +use std::io::Write; + +use clap::Args; +use doublezero_cli_core::CliContext; + +use crate::{client::DaemonClient, ledger::LedgerClient, requirements::check_daemon}; + +/// Enable the reconciler (start managing tunnels) +#[derive(Args, Debug)] +pub struct Enable {} + +impl Enable { + pub async fn execute( + self, + _ctx: &CliContext, + daemon: &D, + ledger: &L, + out: &mut W, + ) -> eyre::Result<()> { + check_daemon(daemon, ledger).await?; + + if let Ok(v2) = daemon.v2_status().await { + if v2.reconciler_enabled { + writeln!(out, "Reconciler already enabled")?; + return Ok(()); + } + } + + daemon.enable().await?; + writeln!(out, "Reconciler enabled")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + client::{MockDaemonClient, V2StatusResponse}, + ledger::MockLedgerClient, + }; + use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; + use doublezero_config::Environment; + + 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); + } + + #[test] + fn test_enable_success() { + 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: false, + client_ip: String::new(), + network: String::new(), + services: vec![], + }) + }); + daemon.expect_enable().returning(|| Ok(())); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Enable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + assert_eq!(output, "Reconciler enabled\n"); + }); + } + + #[test] + fn test_enable_already_enabled() { + 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: String::new(), + services: vec![], + }) + }); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Enable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_ok()); + let output = String::from_utf8(out).unwrap(); + assert_eq!(output, "Reconciler already enabled\n"); + }); + } + + #[test] + fn test_enable_daemon_error() { + 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: false, + client_ip: String::new(), + network: String::new(), + services: vec![], + }) + }); + daemon + .expect_enable() + .returning(|| Err(eyre::eyre!("connection refused"))); + + let ctx = cli_context_default_for_tests(); + let mut out = Vec::new(); + let result = Enable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("connection refused")); + }); + } + + #[test] + fn test_enable_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 = Enable {}.execute(&ctx, &daemon, &ledger, &mut out).await; + + assert!(result.is_err()); + }); + } +} diff --git a/crates/doublezero-daemon-cli/src/lib.rs b/crates/doublezero-daemon-cli/src/lib.rs index ac6c0fc66e..aa737bf217 100644 --- a/crates/doublezero-daemon-cli/src/lib.rs +++ b/crates/doublezero-daemon-cli/src/lib.rs @@ -5,7 +5,10 @@ pub mod cli; pub mod client; +pub mod disable; +pub mod enable; pub mod ledger; +mod requirements; pub use cli::DaemonCommand; pub use client::{DaemonClient, DaemonClientImpl}; diff --git a/crates/doublezero-daemon-cli/src/requirements.rs b/crates/doublezero-daemon-cli/src/requirements.rs new file mode 100644 index 0000000000..fc4ed69611 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/requirements.rs @@ -0,0 +1,35 @@ +//! Shared pre-flight check for daemon verbs. +//! +//! Validates that the daemon socket is present and accessible, and that the +//! daemon and ledger agree on the active environment. + +use crate::{client::DaemonClient, ledger::LedgerClient}; + +pub(crate) async fn check_daemon( + daemon: &D, + ledger: &L, +) -> eyre::Result<()> { + if !daemon.daemon_check() { + tracing::warn!("doublezero service is not accessible."); + eyre::bail!("Please start the doublezerod service."); + } + + if !daemon.daemon_can_open() { + tracing::warn!("doublezero service is not accessible."); + eyre::bail!("Please check the permissions of the doublezerod service."); + } + + let daemon_env = daemon.get_env().await?; + if daemon_env != ledger.get_environment() { + return Err(eyre::eyre!( + "The client and the daemon are using different environments.\n\ +Client: {}\n\ +Daemon: {}\n\ +Please update the daemon configuration so both use the same environment.", + ledger.get_environment(), + daemon_env + )); + } + + Ok(()) +} From 62d68c1a418fc504cf736a03e46943372ca3d1f7 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Thu, 4 Jun 2026 16:23:26 +0000 Subject: [PATCH 2/3] daemon-cli: bind get_environment() once in check_daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid calling ledger.get_environment() twice in the environment mismatch check — bind the result once and reuse it for both comparison and error message. --- crates/doublezero-daemon-cli/src/requirements.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/doublezero-daemon-cli/src/requirements.rs b/crates/doublezero-daemon-cli/src/requirements.rs index fc4ed69611..c4e32d342d 100644 --- a/crates/doublezero-daemon-cli/src/requirements.rs +++ b/crates/doublezero-daemon-cli/src/requirements.rs @@ -20,13 +20,14 @@ pub(crate) async fn check_daemon( } let daemon_env = daemon.get_env().await?; - if daemon_env != ledger.get_environment() { + let client_env = ledger.get_environment(); + if daemon_env != client_env { return Err(eyre::eyre!( "The client and the daemon are using different environments.\n\ Client: {}\n\ Daemon: {}\n\ Please update the daemon configuration so both use the same environment.", - ledger.get_environment(), + client_env, daemon_env )); } From ddfc197ce5513bd193d5a2ebab1cac0a48b31b6c Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Thu, 4 Jun 2026 17:03:18 +0000 Subject: [PATCH 3/3] daemon-cli: remove dead disable method from ServiceController trait --- CHANGELOG.md | 1 + client/doublezero/src/servicecontroller.rs | 18 ------------------ 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a47c4c8b2d..e2035218b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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. - 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/client/doublezero/src/servicecontroller.rs b/client/doublezero/src/servicecontroller.rs index 7d46758422..d0dd66c732 100644 --- a/client/doublezero/src/servicecontroller.rs +++ b/client/doublezero/src/servicecontroller.rs @@ -192,7 +192,6 @@ pub trait ServiceController { async fn status(&self) -> eyre::Result>; async fn v2_status(&self) -> eyre::Result; async fn enable(&self) -> eyre::Result<()>; - async fn disable(&self) -> eyre::Result<()>; async fn routes(&self) -> eyre::Result>; } @@ -340,23 +339,6 @@ impl ServiceController for ServiceControllerImpl { Ok(()) } - async fn disable(&self) -> eyre::Result<()> { - let client: Client> = - Client::builder(TokioExecutor::new()).build(UnixConnector); - let req = Request::builder() - .method(Method::POST) - .uri(Uri::new(&self.socket_path, "/disable")) - .body(Full::from(Bytes::new()))?; - let res = client - .request(req) - .await - .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; - if res.status() != 200 { - eyre::bail!("Failed to disable reconciler: {}", res.status()); - } - Ok(()) - } - async fn routes(&self) -> eyre::Result> { let client = Client::builder(TokioExecutor::new()).build(UnixConnector); let req = Request::builder()