From 84e79739a541f66f20f3fca600f7abf15ce9af59 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 23:37:03 -0500 Subject: [PATCH 01/11] smartcontract,controlplane: add flex-algo link/tenant CLI extensions and migrate command --- .../doublezero-admin/src/cli/command.rs | 7 +- .../doublezero-admin/src/cli/migrate.rs | 180 ++++++++++++++++++ controlplane/doublezero-admin/src/cli/mod.rs | 1 + controlplane/doublezero-admin/src/main.rs | 3 + smartcontract/cli/src/link/get.rs | 44 ++++- smartcontract/cli/src/link/list.rs | 111 +++++++++-- smartcontract/cli/src/link/update.rs | 29 ++- smartcontract/cli/src/tenant/get.rs | 34 +++- smartcontract/cli/src/tenant/list.rs | 42 +++- smartcontract/cli/src/tenant/update.rs | 33 +++- .../sdk/rs/src/commands/tenant/update.rs | 3 +- 11 files changed, 453 insertions(+), 34 deletions(-) create mode 100644 controlplane/doublezero-admin/src/cli/migrate.rs diff --git a/controlplane/doublezero-admin/src/cli/command.rs b/controlplane/doublezero-admin/src/cli/command.rs index 8d3826ed50..aa84e6f51d 100644 --- a/controlplane/doublezero-admin/src/cli/command.rs +++ b/controlplane/doublezero-admin/src/cli/command.rs @@ -2,8 +2,8 @@ use super::{multicast::MulticastCliCommand, sentinel::SentinelCliCommand}; use crate::cli::{ accesspass::AccessPassCliCommand, config::ConfigCliCommand, contributor::ContributorCliCommand, device::DeviceCliCommand, exchange::ExchangeCliCommand, globalconfig::GlobalConfigCliCommand, - link::LinkCliCommand, location::LocationCliCommand, permission::PermissionCliCommand, - tenant::TenantCliCommand, user::UserCliCommand, + link::LinkCliCommand, location::LocationCliCommand, migrate::MigrateCliCommand, + permission::PermissionCliCommand, tenant::TenantCliCommand, user::UserCliCommand, }; use clap::{Args, Subcommand}; use clap_complete::Shell; @@ -69,6 +69,9 @@ pub enum Command { /// Sentinel admin commands #[command()] Sentinel(SentinelCliCommand), + /// Backfill link topologies and report Vpnv4 loopback gaps (RFC-18 migration) + #[command()] + Migrate(MigrateCliCommand), /// Export all data to files #[command()] Export(ExportCliCommand), diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs new file mode 100644 index 0000000000..924f13941a --- /dev/null +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -0,0 +1,180 @@ +use clap::{Args, Subcommand}; +use doublezero_cli::doublezerocommand::CliCommand; +use doublezero_sdk::commands::{ + device::list::ListDeviceCommand, + link::{list::ListLinkCommand, update::UpdateLinkCommand}, + topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, +}; +use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct MigrateCliCommand { + #[command(subcommand)] + pub command: MigrateCommands, +} + +#[derive(Debug, Subcommand)] +pub enum MigrateCommands { + /// Backfill link topologies and Vpnv4 loopback FlexAlgoNodeSegments (RFC-18 migration) + FlexAlgo(FlexAlgoMigrateCliCommand), +} + +#[derive(Args, Debug)] +pub struct FlexAlgoMigrateCliCommand { + /// Print what would be changed without submitting transactions + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +impl FlexAlgoMigrateCliCommand { + pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { + let program_id = client.get_program_id(); + + // Verify UNICAST-DEFAULT topology PDA exists on chain. + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + client + .get_account(unicast_default_pda) + .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; + + // ── Part 1: link topology backfill ─────────────────────────────────────── + + let links = client.list_link(ListLinkCommand)?; + let mut links_tagged = 0u32; + let mut links_needing_tag = 0u32; + let mut links_skipped = 0u32; + + let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); + link_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (pubkey, link) in &link_entries { + if link.link_topologies.is_empty() { + links_needing_tag += 1; + writeln!( + out, + " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", + link.code + )?; + if !self.dry_run { + let result = client.update_link(UpdateLinkCommand { + pubkey: *pubkey, + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }); + match result { + Ok(sig) => { + links_tagged += 1; + writeln!(out, " tagged: {sig}")?; + } + Err(e) => { + writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; + } + } + } + } else { + links_skipped += 1; + } + } + + // ── Part 2: Vpnv4 loopback FlexAlgoNodeSegment backfill ───────────────── + + let topologies = client.list_topology(ListTopologyCommand)?; + let mut topology_entries: Vec<(Pubkey, _)> = topologies.into_iter().collect(); + topology_entries.sort_by_key(|(pk, _)| pk.to_string()); + + let devices = client.list_device(ListDeviceCommand)?; + let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); + device_entries.sort_by_key(|(pk, _)| pk.to_string()); + + let mut topologies_backfilled = 0u32; + let mut topologies_skipped = 0u32; + + for (topology_pubkey, topology) in &topology_entries { + let mut devices_needing_backfill: Vec = Vec::new(); + + for (device_pubkey, device) in &device_entries { + let needs_backfill = device.interfaces.iter().any(|iface| { + let current = iface.into_current_version(); + current.loopback_type == LoopbackType::Vpnv4 + && !current + .flex_algo_node_segments + .iter() + .any(|s| s.topology == *topology_pubkey) + }); + if needs_backfill { + devices_needing_backfill.push(*device_pubkey); + } + } + + if devices_needing_backfill.is_empty() { + topologies_skipped += 1; + continue; + } + + topologies_backfilled += 1; + writeln!( + out, + " [topology] {} ({}) — {} device(s) need backfill", + topology.name, + topology_pubkey, + devices_needing_backfill.len() + )?; + + if !self.dry_run { + let result = client.backfill_topology(BackfillTopologyCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); + match result { + Ok(sig) => { + writeln!(out, " backfilled: {sig}")?; + } + Err(e) => { + writeln!( + out, + " WARNING: failed to backfill topology {}: {e}", + topology.name + )?; + } + } + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + + let dry_run_suffix = if self.dry_run { + " [DRY RUN — no changes made]" + } else { + "" + }; + let tagged_summary = if self.dry_run { + format!("{links_needing_tag} link(s) would be tagged") + } else { + format!("{links_tagged} link(s) tagged") + }; + let loopback_summary = if self.dry_run { + format!("{topologies_backfilled} topology(s) would be backfilled") + } else { + format!("{topologies_backfilled} topology(s) backfilled") + }; + writeln!( + out, + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped; {loopback_summary}, {topologies_skipped} topology(s) already complete{dry_run_suffix}" + )?; + + Ok(()) + } +} diff --git a/controlplane/doublezero-admin/src/cli/mod.rs b/controlplane/doublezero-admin/src/cli/mod.rs index 3985558bed..1fab197adc 100644 --- a/controlplane/doublezero-admin/src/cli/mod.rs +++ b/controlplane/doublezero-admin/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod exchange; pub mod globalconfig; pub mod link; pub mod location; +pub mod migrate; pub mod multicast; pub mod multicastgroup; pub mod permission; diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index 73f4986141..253ee4b0ce 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -256,6 +256,9 @@ async fn main() -> eyre::Result<()> { } }, + Command::Migrate(args) => match args.command { + cli::migrate::MigrateCommands::FlexAlgo(cmd) => cmd.execute(&client, &mut handle), + }, Command::Export(args) => args.execute(&client, &mut handle), Command::Keygen(args) => args.execute(&client, &mut handle), Command::Log(args) => args.execute(&dzclient, &mut handle), diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 696bebb8f1..7187375c9b 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::link::get::GetLinkCommand; +use doublezero_sdk::{commands::link::get::GetLinkCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -47,6 +47,28 @@ struct LinkDisplay { pub status: String, pub health: String, pub owner: String, + pub link_topologies: String, + pub unicast_drained: bool, +} + +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } } impl GetLinkCliCommand { @@ -55,6 +77,10 @@ impl GetLinkCliCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = LinkDisplay { account: pubkey.to_string(), code: link.code, @@ -92,6 +118,10 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, }; if self.json { @@ -126,6 +156,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_link_get() { @@ -146,7 +177,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -158,7 +189,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + link_topologies: Vec::new(), link_flags: 0, }; @@ -242,6 +273,9 @@ mod tests { pubkey_or_code: device2_pk.to_string(), })) .returning(move |_| Ok((device2_pk, device2.clone()))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Expected failure let mut output = Vec::new(); @@ -295,7 +329,7 @@ mod tests { assert_eq!(json["status"].as_str().unwrap(), "activated"); assert_eq!(json["tunnel_type"].as_str().unwrap(), "WAN"); assert_eq!(json["bandwidth"].as_u64().unwrap(), 1_000_000_000); - assert_eq!(json["mtu"].as_u64().unwrap(), 9000); + assert_eq!(json["mtu"].as_u64().unwrap(), 1500); assert_eq!(json["contributor"].as_str().unwrap(), "test-contributor"); assert_eq!(json["side_a"].as_str().unwrap(), "side-a-device"); assert_eq!(json["side_z"].as_str().unwrap(), "side-z-device"); diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index bd8ecc30a8..e9e8b6bdce 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -6,13 +6,14 @@ use doublezero_sdk::{ contributor::{get::GetContributorCommand, list::ListContributorCommand}, device::list::ListDeviceCommand, link::list::ListLinkCommand, + topology::list::ListTopologyCommand, }, - Link, LinkLinkType, LinkStatus, + Link, LinkLinkType, LinkStatus, TopologyInfo, }; use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{io::Write, str::FromStr}; +use std::{collections::HashMap, io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -41,6 +42,9 @@ pub struct ListLinkCliCommand { /// Filter by link code (partial match) #[arg(long)] pub code: Option, + /// Filter by topology name (use "default" for links with no topology assignment) + #[arg(long)] + pub topology: Option, /// List only WAN links. #[arg(long, default_value_t = false)] pub wan: bool, @@ -92,6 +96,28 @@ pub struct LinkDisplay { pub health: LinkHealth, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, + pub link_topologies: String, + pub unicast_drained: bool, +} + +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } } impl ListLinkCliCommand { @@ -99,6 +125,9 @@ impl ListLinkCliCommand { let contributors = client.list_contributor(ListContributorCommand {})?; let devices = client.list_device(ListDeviceCommand)?; let mut links = client.list_link(ListLinkCommand)?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); // Filter by contributor if specified if let Some(contributor_filter) = &self.contributor { @@ -179,6 +208,20 @@ impl ListLinkCliCommand { links.retain(|(_, link)| link.code.contains(code_filter)); } + // Filter by topology if specified + if let Some(topology_filter) = &self.topology { + if topology_filter == "default" { + links.retain(|(_, link)| link.link_topologies.is_empty()); + } else { + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *topology_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", topology_filter))?; + links.retain(|(_, link)| link.link_topologies.contains(&topology_pk)); + } + } + let mut tunnel_displays: Vec = links .into_iter() .map(|(pubkey, link)| { @@ -217,6 +260,10 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, } }) .collect(); @@ -377,7 +424,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -386,6 +434,9 @@ mod tests { tunnels.insert(tunnel1_pubkey, tunnel1.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -397,6 +448,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -406,7 +458,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | default | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -418,6 +470,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -427,7 +480,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\"}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"default\",\"unicast_drained\":false}]\n"); } #[test] @@ -573,7 +626,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; let tunnel2_pubkey = Pubkey::new_unique(); @@ -587,7 +641,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 40_000, jitter_ns: 2000, delay_override_ns: 0, @@ -599,7 +653,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -609,6 +664,9 @@ mod tests { tunnels.insert(tunnel2_pubkey, tunnel2.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -620,6 +678,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -749,7 +808,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -764,7 +824,7 @@ mod tests { side_z_pk: device2_pubkey, link_type: LinkLinkType::DZX, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -776,7 +836,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -786,6 +847,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by link_type=WAN (should return only link1) let mut output = Vec::new(); @@ -798,6 +862,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -926,7 +991,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -941,7 +1007,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -953,7 +1019,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -963,6 +1030,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by side_a=device_ams (should return only link1) let mut output = Vec::new(); @@ -975,6 +1045,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -1070,7 +1141,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -1085,7 +1157,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -1097,7 +1169,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - link_topologies: vec![], + + link_topologies: Vec::new(), link_flags: 0, }; @@ -1107,6 +1180,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by code=production (should return only link1) let mut output = Vec::new(); @@ -1119,6 +1195,7 @@ mod tests { health: None, desired_status: None, code: Some("production".to_string()), + topology: None, wan: false, dzx: false, json: false, diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index d8244ecaac..e5ea6785a7 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -13,6 +13,7 @@ use doublezero_program_common::types::NetworkV4; use doublezero_sdk::commands::{ contributor::get::GetContributorCommand, link::{get::GetLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, }; use doublezero_serviceability::state::link::LinkDesiredStatus; use eyre::eyre; @@ -59,6 +60,12 @@ pub struct UpdateLinkCliCommand { /// Reassign tunnel network (foundation-only, e.g. 172.16.1.100/31) #[arg(long)] pub tunnel_net: Option, + /// Topology name to tag this link with (foundation-only). Use "default" to clear. + #[arg(long)] + pub link_topology: Option, + /// Mark this link as unicast-drained (contributor or foundation) + #[arg(long)] + pub unicast_drained: Option, /// Wait for the device to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -115,6 +122,20 @@ impl UpdateLinkCliCommand { } } + let link_topologies = match self.link_topology { + None => None, + Some(ref name) if name == "default" => Some(vec![]), + Some(ref name) => { + let topology_map = client.list_topology(ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre!("Topology '{}' not found", name))?; + Some(vec![topology_pk]) + } + }; + let signature = client.update_link(UpdateLinkCommand { pubkey, code: self.code.clone(), @@ -133,8 +154,8 @@ impl UpdateLinkCliCommand { desired_status: self.desired_status, tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, - link_topologies: None, - unicast_drained: None, + link_topologies, + unicast_drained: self.unicast_drained, })?; writeln!(out, "Signature: {signature}",)?; @@ -309,6 +330,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); @@ -334,6 +357,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 0b1e82acb7..fecd472d52 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::get::GetTenantCommand; +use doublezero_sdk::{commands::tenant::get::GetTenantCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -30,16 +30,41 @@ struct TenantDisplay { pub administrators: String, pub token_account: String, pub reference_count: u32, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, tenant) = client.get_tenant(GetTenantCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = TenantDisplay { account: pubkey, code: tenant.code, @@ -56,6 +81,7 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, + include_topologies: resolve_topology_names(&tenant.include_topologies, &topology_map), owner: tenant.owner, }; @@ -84,6 +110,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_tenant_get() { @@ -123,6 +150,9 @@ mod tests { client .expect_get_tenant() .returning(move |_| Err(eyre::eyre!("not found"))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ // Expected failure diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 20d06a8e46..356c6f3ea5 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -1,10 +1,13 @@ use crate::doublezerocommand::CliCommand; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::list::ListTenantCommand; +use doublezero_sdk::{ + commands::{tenant::list::ListTenantCommand, topology::list::ListTopologyCommand}, + TopologyInfo, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -25,13 +28,37 @@ pub struct TenantDisplay { pub vrf_id: u16, pub metro_routing: bool, pub route_liveness: bool, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let tenants = client.list_tenant(ListTenantCommand {})?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); let mut tenant_displays: Vec = tenants .into_iter() @@ -41,6 +68,10 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, + include_topologies: resolve_topology_names( + &tenant.include_topologies, + &topology_map, + ), owner: tenant.owner, }) .collect(); @@ -97,6 +128,9 @@ mod tests { client .expect_list_tenant() .returning(move |_| Ok(HashMap::from([(tenant1_pubkey, tenant1.clone())]))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ let mut output = Vec::new(); @@ -109,7 +143,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | default | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -122,7 +156,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"default\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index fbd8cfc83b..acaeb05bd7 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -4,7 +4,10 @@ use crate::{ validators::validate_pubkey_or_code, }; use clap::Args; -use doublezero_sdk::commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}; +use doublezero_sdk::{ + commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}, + get_topology_pda, +}; use doublezero_serviceability::state::tenant::{FlatPerEpochConfig, TenantBillingConfig}; use solana_sdk::pubkey::Pubkey; use std::{io::Write, str::FromStr}; @@ -29,6 +32,9 @@ pub struct UpdateTenantCliCommand { /// Flat billing rate per epoch (in lamports) #[arg(long)] pub billing_rate: Option, + /// Comma-separated topology names to assign to this tenant (foundation-only). Use "default" to clear. + #[arg(long)] + pub include_topologies: Option, } impl UpdateTenantCliCommand { @@ -54,6 +60,28 @@ impl UpdateTenantCliCommand { }) }); + let include_topologies = if let Some(ref topo_arg) = self.include_topologies { + if topo_arg == "default" { + Some(vec![]) + } else { + let program_id = client.get_program_id(); + let pubkeys: eyre::Result> = topo_arg + .split(',') + .map(|name| { + let name = name.trim(); + let pda = get_topology_pda(&program_id, name).0; + client + .get_account(pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", name))?; + Ok(pda) + }) + .collect(); + Some(pubkeys?) + } + } else { + None + }; + let signature = client.update_tenant(UpdateTenantCommand { tenant_pubkey, vrf_id: self.vrf_id, @@ -61,6 +89,7 @@ impl UpdateTenantCliCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing, + include_topologies, })?; writeln!(out, "Signature: {signature}")?; @@ -133,6 +162,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing: None, + include_topologies: None, })) .returning(move |_| Ok(signature)); @@ -145,6 +175,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing_rate: None, + include_topologies: None, } .execute(&client, &mut output); assert!(res.is_ok()); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index b0b511e37d..97a7eb109d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -13,6 +13,7 @@ pub struct UpdateTenantCommand { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + pub include_topologies: Option>, } impl UpdateTenantCommand { @@ -26,7 +27,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, - include_topologies: None, + include_topologies: self.include_topologies.clone(), }), vec![ AccountMeta::new(self.tenant_pubkey, false), From 6779e525d448085eafc8df8311933db882c0c9ef Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Fri, 10 Apr 2026 00:03:58 -0500 Subject: [PATCH 02/11] smartcontract,controlplane: add CHANGELOG entries for flex-algo CLI extensions --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4014fcfce7..f2630aee73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,8 @@ All notable changes to this project will be documented in this file. - Add `link_topologies: Vec` (capped at 8) and `link_flags: u32` (bit 0 = unicast-drained) to the `Link` account - Add `include_topologies` to the `Tenant` account for topology-filtered routing opt-in - Enforce UNICAST-DEFAULT topology existence as a precondition for link activation + - Extend `link get` and `link list` to display topology assignments and drain status; add `--link-topology ` filter to `link list` and `--link-topology`/`--unicast-drained` flags to `link update` + - Extend `tenant get` and `tenant list` to display included topologies; add `--include-topologies` flag to `tenant update` - Onchain programs - Add `tunnel_endpoint` field to the `UpdateUser` instruction (`UserUpdateArgs`), allowing the activator to overwrite a user's tunnel endpoint onchain; field is optional and backward compatible via incremental deserialization - Telemetry @@ -100,6 +102,7 @@ All notable changes to this project will be documented in this file. - Add BGP status submitter: on each tick, reads BGP socket state from the device namespace, maps each activated user to their tunnel peer IP, and submits `SetUserBGPStatus` onchain; supports a configurable down grace period and periodic keepalive refresh; enabled via `--bgp-status-enable` with `--bgp-status-interval`, `--bgp-status-refresh-interval`, and `--bgp-status-down-grace-period` flags - Tools - Add `IsRetryableFunc` field to `RetryOptions` for configurable retry criteria in the Solana JSON-RPC client; add `"rate limited"` string match and RPC code `-32429` to the default implementation + - Add `doublezero-admin migrate flex-algo [--dry-run]` command to backfill link topology assignments and VPNv4 loopback flex-algo node segments across all existing devices and links - Geolocation - Standardize CLI flag naming: probe mutation commands use `--probe` (was `--code`) accepting pubkey or code; rename `--signing-keypair` → `--signing-pubkey` and `--target-pk` → `--target-signing-pubkey`; add `--json-compact` to `get` commands - geoprobe-target can now store LocationOffset messages in ClickHouse From acad849031365a544c9050f59472f8493161d3c8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 13 Apr 2026 17:07:11 -0500 Subject: [PATCH 03/11] smartcontract,e2e: fix update_link account ordering, topology PDAs, and geolocation init error --- e2e/internal/devnet/smartcontract_geolocation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/internal/devnet/smartcontract_geolocation.go b/e2e/internal/devnet/smartcontract_geolocation.go index a93d9cd425..841894ed24 100644 --- a/e2e/internal/devnet/smartcontract_geolocation.go +++ b/e2e/internal/devnet/smartcontract_geolocation.go @@ -81,7 +81,7 @@ func (dn *Devnet) InitGeolocationProgramConfigIfNotInitialized(ctx context.Conte }, docker.NoPrintOnError()) if err != nil { outputStr := strings.ToLower(string(output)) - if strings.Contains(outputStr, "already") || strings.Contains(outputStr, "already in use") { + if strings.Contains(outputStr, "already") || strings.Contains(outputStr, "already in use") || strings.Contains(outputStr, "uninitialized account") { dn.log.Debug("--> Geolocation program config is already initialized") return false, nil } From 8590c0bd79ed7b09c1917c1649d9b12d3ecfba79 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 13 Apr 2026 17:37:55 -0500 Subject: [PATCH 04/11] e2e: add doublezero-admin binary to manager container --- e2e/docker/manager/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker/manager/Dockerfile b/e2e/docker/manager/Dockerfile index a304ec5d9f..3efb51038b 100644 --- a/e2e/docker/manager/Dockerfile +++ b/e2e/docker/manager/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && \ apt-get install -y curl perl jq vim iproute2 COPY --from=base /doublezero/bin/doublezero /doublezero/bin/. +COPY --from=base /doublezero/bin/doublezero-admin /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_serviceability.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_telemetry.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_geolocation.so /doublezero/bin/. From 806c37c29c7f6623e61c665560c0c28a92d49172 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 13 Apr 2026 23:07:59 -0500 Subject: [PATCH 05/11] controlplane: fix migrate flex-algo to use uppercase UNICAST-DEFAULT name --- controlplane/doublezero-admin/src/cli/migrate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 924f13941a..1c38c843b8 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -33,7 +33,7 @@ impl FlexAlgoMigrateCliCommand { let program_id = client.get_program_id(); // Verify UNICAST-DEFAULT topology PDA exists on chain. - let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let (unicast_default_pda, _) = get_topology_pda(&program_id, "UNICAST-DEFAULT"); client .get_account(unicast_default_pda) .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; From 127220a197a50102bf6b060c1e6888c70a34981f Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 14 Apr 2026 14:34:42 -0500 Subject: [PATCH 06/11] smartcontract/cli: normalize topology names to lowercase at input topology create/delete/clear/backfill and link update all normalize the user-supplied topology name to lowercase before deriving PDAs or comparing against stored names. clear and link update use case-insensitive comparison to handle existing uppercase data during transition. --- smartcontract/cli/src/link/update.rs | 3 ++- smartcontract/cli/src/tenant/update.rs | 4 ++-- smartcontract/cli/src/topology/backfill.rs | 6 ++++-- smartcontract/cli/src/topology/clear.rs | 12 +++++++----- smartcontract/cli/src/topology/create.rs | 9 +++++---- smartcontract/cli/src/topology/delete.rs | 12 +++++++----- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index e5ea6785a7..438b2eb62e 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -126,10 +126,11 @@ impl UpdateLinkCliCommand { None => None, Some(ref name) if name == "default" => Some(vec![]), Some(ref name) => { + let name = name.to_lowercase(); let topology_map = client.list_topology(ListTopologyCommand)?; let topology_pk = topology_map .iter() - .find(|(_, t)| t.name == *name) + .find(|(_, t)| t.name.to_lowercase() == name) .map(|(pk, _)| *pk) .ok_or_else(|| eyre!("Topology '{}' not found", name))?; Some(vec![topology_pk]) diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index acaeb05bd7..2678d5e838 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -68,8 +68,8 @@ impl UpdateTenantCliCommand { let pubkeys: eyre::Result> = topo_arg .split(',') .map(|name| { - let name = name.trim(); - let pda = get_topology_pda(&program_id, name).0; + let name = name.trim().to_lowercase(); + let pda = get_topology_pda(&program_id, &name).0; client .get_account(pda) .map_err(|_| eyre::eyre!("Topology '{}' not found", name))?; diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/backfill.rs index 6863381a15..8c75621544 100644 --- a/smartcontract/cli/src/topology/backfill.rs +++ b/smartcontract/cli/src/topology/backfill.rs @@ -27,15 +27,17 @@ impl BackfillTopologyCliCommand { )); } + let name = self.name.to_lowercase(); + let sigs = client.backfill_topology(BackfillTopologyCommand { - name: self.name.clone(), + name: name.clone(), device_pubkeys: self.device_pubkeys, })?; writeln!( out, "Backfilled topology '{}' across {} transaction(s).", - self.name, + name, sigs.len() )?; diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs index 5997f3db22..33d53609b1 100644 --- a/smartcontract/cli/src/topology/clear.rs +++ b/smartcontract/cli/src/topology/clear.rs @@ -22,15 +22,17 @@ impl ClearTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + let name = self.name.to_lowercase(); + let link_pubkeys: Vec = if self.links.is_empty() { // Auto-discover: find all links tagged with this topology. let topology_map = client .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand)?; let topology_pk = topology_map .iter() - .find(|(_, t)| t.name == self.name) + .find(|(_, t)| t.name.to_lowercase() == name) .map(|(pk, _)| *pk) - .ok_or_else(|| eyre::eyre!("Topology '{}' not found", self.name))?; + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", name))?; let links = client.list_link(doublezero_sdk::commands::link::list::ListLinkCommand)?; links @@ -54,20 +56,20 @@ impl ClearTopologyCliCommand { writeln!( out, "No links tagged with topology '{}'. Nothing to clear.", - self.name + name )?; return Ok(()); } client.clear_topology(ClearTopologyCommand { - name: self.name.clone(), + name: name.clone(), link_pubkeys, })?; writeln!( out, "Cleared topology '{}' from {} link(s).", - self.name, total + name, total )?; Ok(()) diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index faa55c42fe..e914ee2889 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -30,23 +30,24 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { - if self.name.len() > 32 { + let name = self.name.to_lowercase(); + if name.len() > 32 { eyre::bail!( "topology name must be 32 characters or fewer (got {})", - self.name.len() + name.len() ); } client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; let result = client.create_topology(CreateTopologyCommand { - name: self.name.clone(), + name: name.clone(), constraint: self.constraint, })?; writeln!( out, "Created topology '{}' successfully. PDA: {}. Backfilled {} transaction(s).", - self.name, + name, result.topology_pda, result.backfill_signatures.len() )?; diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index d92cd7a661..ca010ab204 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -20,9 +20,11 @@ impl DeleteTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + let name = self.name.to_lowercase(); + // Guard: check if any links still reference this topology let program_id = client.get_program_id(); - let topology_pda = get_topology_pda(&program_id, &self.name).0; + let topology_pda = get_topology_pda(&program_id, &name).0; let links = client.list_link(ListLinkCommand)?; let referencing_count = links .values() @@ -31,16 +33,16 @@ impl DeleteTopologyCliCommand { if referencing_count > 0 { return Err(eyre::eyre!( "Cannot delete topology '{}': {} link(s) still reference it. Run 'doublezero link topology clear --name {}' first.", - self.name, + name, referencing_count, - self.name, + name, )); } client.delete_topology(DeleteTopologyCommand { - name: self.name.clone(), + name: name.clone(), })?; - writeln!(out, "Deleted topology '{}' successfully.", self.name)?; + writeln!(out, "Deleted topology '{}' successfully.", name)?; Ok(()) } From a848c6c36ae5eae36db0fa2afa338695586260c4 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 14 Apr 2026 14:35:13 -0500 Subject: [PATCH 07/11] smartcontract/cli: rustfmt --- smartcontract/cli/src/topology/clear.rs | 6 +----- smartcontract/cli/src/topology/delete.rs | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs index 33d53609b1..c803c2e100 100644 --- a/smartcontract/cli/src/topology/clear.rs +++ b/smartcontract/cli/src/topology/clear.rs @@ -66,11 +66,7 @@ impl ClearTopologyCliCommand { link_pubkeys, })?; - writeln!( - out, - "Cleared topology '{}' from {} link(s).", - name, total - )?; + writeln!(out, "Cleared topology '{}' from {} link(s).", name, total)?; Ok(()) } diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index ca010ab204..a3c8fa1d41 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -39,9 +39,7 @@ impl DeleteTopologyCliCommand { )); } - client.delete_topology(DeleteTopologyCommand { - name: name.clone(), - })?; + client.delete_topology(DeleteTopologyCommand { name: name.clone() })?; writeln!(out, "Deleted topology '{}' successfully.", name)?; Ok(()) From 801523dd8d1b6506300ad0ed952d7677fe9d4337 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 14 Apr 2026 15:13:27 -0500 Subject: [PATCH 08/11] smartcontract/cli: support comma-separated topology names in --link-topology --- smartcontract/cli/src/link/update.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 438b2eb62e..c1c50a938d 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -60,7 +60,7 @@ pub struct UpdateLinkCliCommand { /// Reassign tunnel network (foundation-only, e.g. 172.16.1.100/31) #[arg(long)] pub tunnel_net: Option, - /// Topology name to tag this link with (foundation-only). Use "default" to clear. + /// Comma-separated topology names to tag this link with (foundation-only). Use "default" to clear. #[arg(long)] pub link_topology: Option, /// Mark this link as unicast-drained (contributor or foundation) @@ -124,16 +124,21 @@ impl UpdateLinkCliCommand { let link_topologies = match self.link_topology { None => None, - Some(ref name) if name == "default" => Some(vec![]), - Some(ref name) => { - let name = name.to_lowercase(); + Some(ref names_str) if names_str == "default" => Some(vec![]), + Some(ref names_str) => { let topology_map = client.list_topology(ListTopologyCommand)?; - let topology_pk = topology_map - .iter() - .find(|(_, t)| t.name.to_lowercase() == name) - .map(|(pk, _)| *pk) - .ok_or_else(|| eyre!("Topology '{}' not found", name))?; - Some(vec![topology_pk]) + let pubkeys: eyre::Result> = names_str + .split(',') + .map(|name| { + let name = name.trim().to_lowercase(); + topology_map + .iter() + .find(|(_, t)| t.name.to_lowercase() == name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre!("Topology '{}' not found", name)) + }) + .collect(); + Some(pubkeys?) } }; From 5a971ecc2be0c9ff4db2918832c59ccf3a266755 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 14 Apr 2026 19:41:59 -0500 Subject: [PATCH 09/11] smartcontract/cli: clarify comma-separated topology names in CHANGELOG --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2630aee73..dfde629322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,8 +91,8 @@ All notable changes to this project will be documented in this file. - Add `link_topologies: Vec` (capped at 8) and `link_flags: u32` (bit 0 = unicast-drained) to the `Link` account - Add `include_topologies` to the `Tenant` account for topology-filtered routing opt-in - Enforce UNICAST-DEFAULT topology existence as a precondition for link activation - - Extend `link get` and `link list` to display topology assignments and drain status; add `--link-topology ` filter to `link list` and `--link-topology`/`--unicast-drained` flags to `link update` - - Extend `tenant get` and `tenant list` to display included topologies; add `--include-topologies` flag to `tenant update` + - Extend `link get` and `link list` to display topology assignments and drain status; add `--link-topology ` filter to `link list` and `--link-topology` (comma-separated topology names) / `--unicast-drained` flags to `link update`; use `default` as the value to clear all topology assignments + - Extend `tenant get` and `tenant list` to display included topologies; add `--include-topologies` (comma-separated topology names) flag to `tenant update`; use `default` to clear - Onchain programs - Add `tunnel_endpoint` field to the `UpdateUser` instruction (`UserUpdateArgs`), allowing the activator to overwrite a user's tunnel endpoint onchain; field is optional and backward compatible via incremental deserialization - Telemetry From 808efa184202ffda42c4e2462ffbc3e1c5213fd4 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Wed, 22 Apr 2026 17:26:19 +0000 Subject: [PATCH 10/11] pr fixes --- smartcontract/cli/src/link/get.rs | 28 +--- smartcontract/cli/src/link/list.rs | 31 +--- smartcontract/cli/src/link/update.rs | 6 +- smartcontract/cli/src/tenant/get.rs | 29 +--- smartcontract/cli/src/tenant/list.rs | 29 +--- smartcontract/cli/src/tenant/update.rs | 4 +- smartcontract/cli/src/topology/backfill.rs | 6 +- smartcontract/cli/src/topology/clear.rs | 14 +- smartcontract/cli/src/topology/create.rs | 6 +- smartcontract/cli/src/topology/delete.rs | 12 +- smartcontract/cli/src/topology/mod.rs | 24 +++ .../doublezero-serviceability/src/error.rs | 4 + .../src/processors/topology/create.rs | 19 +-- .../src/state/topology.rs | 142 +++++++++++++++++- .../tests/topology_test.rs | 55 ++++++- 15 files changed, 262 insertions(+), 147 deletions(-) diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 7187375c9b..a171969442 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -1,10 +1,12 @@ -use crate::{doublezerocommand::CliCommand, validators::validate_code}; +use crate::{ + doublezerocommand::CliCommand, topology::resolve_topology_names, validators::validate_code, +}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::{commands::link::get::GetLinkCommand, TopologyInfo}; +use doublezero_sdk::commands::link::get::GetLinkCommand; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashMap, io::Write}; +use std::io::Write; use tabled::Tabled; #[derive(Args, Debug)] @@ -51,26 +53,6 @@ struct LinkDisplay { pub unicast_drained: bool, } -fn resolve_topology_names( - pubkeys: &[Pubkey], - topology_map: &HashMap, -) -> String { - if pubkeys.is_empty() { - "default".to_string() - } else { - pubkeys - .iter() - .map(|pk| { - topology_map - .get(pk) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pk.to_string()) - }) - .collect::>() - .join(", ") - } -} - impl GetLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, link) = client.get_link(GetLinkCommand { diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index e9e8b6bdce..0480c938a6 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -1,4 +1,4 @@ -use crate::doublezerocommand::CliCommand; +use crate::{doublezerocommand::CliCommand, topology::resolve_topology_names}; use clap::Args; use doublezero_program_common::{serializer, types::NetworkV4}; use doublezero_sdk::{ @@ -8,12 +8,12 @@ use doublezero_sdk::{ link::list::ListLinkCommand, topology::list::ListTopologyCommand, }, - Link, LinkLinkType, LinkStatus, TopologyInfo, + Link, LinkLinkType, LinkStatus, }; use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashMap, io::Write, str::FromStr}; +use std::{io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -100,26 +100,6 @@ pub struct LinkDisplay { pub unicast_drained: bool, } -fn resolve_topology_names( - pubkeys: &[Pubkey], - topology_map: &HashMap, -) -> String { - if pubkeys.is_empty() { - "default".to_string() - } else { - pubkeys - .iter() - .map(|pk| { - topology_map - .get(pk) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pk.to_string()) - }) - .collect::>() - .join(", ") - } -} - impl ListLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let contributors = client.list_contributor(ListContributorCommand {})?; @@ -210,12 +190,13 @@ impl ListLinkCliCommand { // Filter by topology if specified if let Some(topology_filter) = &self.topology { - if topology_filter == "default" { + let topology_filter = topology_filter.to_uppercase(); + if topology_filter == "DEFAULT" { links.retain(|(_, link)| link.link_topologies.is_empty()); } else { let topology_pk = topology_map .iter() - .find(|(_, t)| t.name == *topology_filter) + .find(|(_, t)| t.name == topology_filter) .map(|(pk, _)| *pk) .ok_or_else(|| eyre::eyre!("Topology '{}' not found", topology_filter))?; links.retain(|(_, link)| link.link_topologies.contains(&topology_pk)); diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index c1c50a938d..db9b752fc2 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -124,16 +124,16 @@ impl UpdateLinkCliCommand { let link_topologies = match self.link_topology { None => None, - Some(ref names_str) if names_str == "default" => Some(vec![]), + Some(ref names_str) if names_str.eq_ignore_ascii_case("default") => Some(vec![]), Some(ref names_str) => { let topology_map = client.list_topology(ListTopologyCommand)?; let pubkeys: eyre::Result> = names_str .split(',') .map(|name| { - let name = name.trim().to_lowercase(); + let name = name.trim().to_uppercase(); topology_map .iter() - .find(|(_, t)| t.name.to_lowercase() == name) + .find(|(_, t)| t.name == name) .map(|(pk, _)| *pk) .ok_or_else(|| eyre!("Topology '{}' not found", name)) }) diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index fecd472d52..5276f99ba9 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -1,10 +1,13 @@ -use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; +use crate::{ + doublezerocommand::CliCommand, topology::resolve_topology_names, + validators::validate_pubkey_or_code, +}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::{commands::tenant::get::GetTenantCommand, TopologyInfo}; +use doublezero_sdk::commands::tenant::get::GetTenantCommand; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashMap, io::Write}; +use std::io::Write; use tabled::Tabled; #[derive(Args, Debug)] @@ -35,26 +38,6 @@ struct TenantDisplay { pub owner: Pubkey, } -fn resolve_topology_names( - pubkeys: &[Pubkey], - topology_map: &HashMap, -) -> String { - if pubkeys.is_empty() { - "default".to_string() - } else { - pubkeys - .iter() - .map(|pk| { - topology_map - .get(pk) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pk.to_string()) - }) - .collect::>() - .join(", ") - } -} - impl GetTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, tenant) = client.get_tenant(GetTenantCommand { diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 356c6f3ea5..8028b8027a 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -1,13 +1,12 @@ -use crate::doublezerocommand::CliCommand; +use crate::{doublezerocommand::CliCommand, topology::resolve_topology_names}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::{ - commands::{tenant::list::ListTenantCommand, topology::list::ListTopologyCommand}, - TopologyInfo, +use doublezero_sdk::commands::{ + tenant::list::ListTenantCommand, topology::list::ListTopologyCommand, }; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashMap, io::Write}; +use std::io::Write; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -33,26 +32,6 @@ pub struct TenantDisplay { pub owner: Pubkey, } -fn resolve_topology_names( - pubkeys: &[Pubkey], - topology_map: &HashMap, -) -> String { - if pubkeys.is_empty() { - "default".to_string() - } else { - pubkeys - .iter() - .map(|pk| { - topology_map - .get(pk) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pk.to_string()) - }) - .collect::>() - .join(", ") - } -} - impl ListTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let tenants = client.list_tenant(ListTenantCommand {})?; diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index 2678d5e838..321470f890 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -61,14 +61,14 @@ impl UpdateTenantCliCommand { }); let include_topologies = if let Some(ref topo_arg) = self.include_topologies { - if topo_arg == "default" { + if topo_arg.eq_ignore_ascii_case("default") { Some(vec![]) } else { let program_id = client.get_program_id(); let pubkeys: eyre::Result> = topo_arg .split(',') .map(|name| { - let name = name.trim().to_lowercase(); + let name = name.trim().to_uppercase(); let pda = get_topology_pda(&program_id, &name).0; client .get_account(pda) diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/backfill.rs index 8c75621544..a35eb7a045 100644 --- a/smartcontract/cli/src/topology/backfill.rs +++ b/smartcontract/cli/src/topology/backfill.rs @@ -27,7 +27,7 @@ impl BackfillTopologyCliCommand { )); } - let name = self.name.to_lowercase(); + let name = self.name.to_uppercase(); let sigs = client.backfill_topology(BackfillTopologyCommand { name: name.clone(), @@ -62,7 +62,7 @@ mod tests { mock.expect_check_requirements().returning(|_| Ok(())); mock.expect_backfill_topology() .with(eq(BackfillTopologyCommand { - name: "unicast-default".to_string(), + name: "UNICAST-DEFAULT".to_string(), device_pubkeys: vec![device1], })) .returning(|_| Ok(vec![Signature::new_unique()])); @@ -75,7 +75,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Backfilled topology 'unicast-default' across 1 transaction(s).")); + assert!(output.contains("Backfilled topology 'UNICAST-DEFAULT' across 1 transaction(s).")); } #[test] diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs index c803c2e100..c74b779f6b 100644 --- a/smartcontract/cli/src/topology/clear.rs +++ b/smartcontract/cli/src/topology/clear.rs @@ -22,7 +22,7 @@ impl ClearTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; - let name = self.name.to_lowercase(); + let name = self.name.to_uppercase(); let link_pubkeys: Vec = if self.links.is_empty() { // Auto-discover: find all links tagged with this topology. @@ -30,7 +30,7 @@ impl ClearTopologyCliCommand { .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand)?; let topology_pk = topology_map .iter() - .find(|(_, t)| t.name.to_lowercase() == name) + .find(|(_, t)| t.name == name) .map(|(pk, _)| *pk) .ok_or_else(|| eyre::eyre!("Topology '{}' not found", name))?; @@ -93,7 +93,7 @@ mod tests { account_type: doublezero_sdk::AccountType::Topology, owner: Pubkey::default(), bump_seed: 0, - name: "unicast-default".to_string(), + name: "UNICAST-DEFAULT".to_string(), admin_group_bit: 1, flex_algo_number: 129, constraint: @@ -115,7 +115,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("No links tagged with topology 'unicast-default'.")); + assert!(output.contains("No links tagged with topology 'UNICAST-DEFAULT'.")); } #[test] @@ -127,7 +127,7 @@ mod tests { mock.expect_check_requirements().returning(|_| Ok(())); mock.expect_clear_topology() .with(eq(ClearTopologyCommand { - name: "unicast-default".to_string(), + name: "UNICAST-DEFAULT".to_string(), link_pubkeys: vec![link1, link2], })) .returning(|_| Ok(vec![Signature::new_unique()])); @@ -140,7 +140,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Cleared topology 'unicast-default' from 2 link(s).")); + assert!(output.contains("Cleared topology 'UNICAST-DEFAULT' from 2 link(s).")); } #[test] @@ -176,6 +176,6 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("Topology 'nonexistent' not found")); + .contains("Topology 'NONEXISTENT' not found")); } } diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index e914ee2889..8fd36bef60 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -30,7 +30,7 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { - let name = self.name.to_lowercase(); + let name = self.name.to_uppercase(); if name.len() > 32 { eyre::bail!( "topology name must be 32 characters or fewer (got {})", @@ -74,7 +74,7 @@ mod tests { mock.expect_check_requirements().returning(|_| Ok(())); mock.expect_create_topology() .with(eq(CreateTopologyCommand { - name: "unicast-default".to_string(), + name: "UNICAST-DEFAULT".to_string(), constraint: TopologyConstraint::IncludeAny, })) .returning(move |_| { @@ -93,7 +93,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Created topology 'unicast-default' successfully.")); + assert!(output.contains("Created topology 'UNICAST-DEFAULT' successfully.")); assert!(output.contains(&topology_pda.to_string())); assert!(output.contains("Backfilled 0 transaction(s).")); } diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index a3c8fa1d41..9b3bf8560e 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -20,7 +20,7 @@ impl DeleteTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; - let name = self.name.to_lowercase(); + let name = self.name.to_uppercase(); // Guard: check if any links still reference this topology let program_id = client.get_program_id(); @@ -72,7 +72,7 @@ mod tests { mock.expect_list_link().returning(|_| Ok(HashMap::new())); mock.expect_delete_topology() .with(eq(DeleteTopologyCommand { - name: "unicast-default".to_string(), + name: "UNICAST-DEFAULT".to_string(), })) .returning(|_| Ok(Signature::new_unique())); @@ -83,7 +83,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Deleted topology 'unicast-default' successfully.")); + assert!(output.contains("Deleted topology 'UNICAST-DEFAULT' successfully.")); } #[test] @@ -93,7 +93,7 @@ mod tests { client.expect_check_requirements().returning(|_| Ok(())); let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); - let topology_pda = get_topology_pda(&program_id, "unicast-default").0; + let topology_pda = get_topology_pda(&program_id, "UNICAST-DEFAULT").0; let link = Link { account_type: AccountType::Link, @@ -134,8 +134,8 @@ mod tests { let result = cmd.execute(&client, &mut out); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("Cannot delete topology 'unicast-default'")); + assert!(err.contains("Cannot delete topology 'UNICAST-DEFAULT'")); assert!(err.contains("1 link(s) still reference it")); - assert!(err.contains("doublezero link topology clear --name unicast-default")); + assert!(err.contains("doublezero link topology clear --name UNICAST-DEFAULT")); } } diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs index 01fa6fd760..64a97eba37 100644 --- a/smartcontract/cli/src/topology/mod.rs +++ b/smartcontract/cli/src/topology/mod.rs @@ -3,3 +3,27 @@ pub mod clear; pub mod create; pub mod delete; pub mod list; + +use doublezero_sdk::TopologyInfo; +use solana_sdk::pubkey::Pubkey; +use std::collections::HashMap; + +pub fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 93c07078ef..b4cb09b217 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -178,6 +178,8 @@ pub enum DoubleZeroError { MaxMulticastPublishersExceeded, // variant 85 #[error("Arithmetic overflow")] ArithmeticOverflow, // variant 86 + #[error("Invalid name")] + InvalidName, // variant 87 } impl From for ProgramError { @@ -270,6 +272,7 @@ impl From for ProgramError { DoubleZeroError::FeatureNotEnabled => ProgramError::Custom(84), DoubleZeroError::MaxMulticastPublishersExceeded => ProgramError::Custom(85), DoubleZeroError::ArithmeticOverflow => ProgramError::Custom(86), + DoubleZeroError::InvalidName => ProgramError::Custom(87), } } } @@ -363,6 +366,7 @@ impl From for DoubleZeroError { 84 => DoubleZeroError::FeatureNotEnabled, 85 => DoubleZeroError::MaxMulticastPublishersExceeded, 86 => DoubleZeroError::ArithmeticOverflow, + 87 => DoubleZeroError::InvalidName, _ => DoubleZeroError::Custom(e), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index b9a35c2b3e..382cffd4bd 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -8,7 +8,7 @@ use crate::{ state::{ accounttype::AccountType, globalstate::GlobalState, - topology::{TopologyConstraint, TopologyInfo}, + topology::{validate_topology_name, TopologyConstraint, TopologyInfo}, }, }; use borsh::BorshSerialize; @@ -21,8 +21,6 @@ use solana_program::{ pubkey::Pubkey, }; -pub const MAX_TOPOLOGY_NAME_LEN: usize = 32; - #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] pub struct TopologyCreateArgs { pub name: String, @@ -65,17 +63,12 @@ pub fn process_topology_create( return Err(DoubleZeroError::Unauthorized.into()); } - // Normalize name to uppercase + // Normalize name to canonical uppercase form and validate format. let name = value.name.to_ascii_uppercase(); - - // Validate name length - if name.len() > MAX_TOPOLOGY_NAME_LEN { - msg!( - "TopologyCreate: name exceeds {} bytes", - MAX_TOPOLOGY_NAME_LEN - ); - return Err(DoubleZeroError::InvalidArgument.into()); - } + validate_topology_name(&name).map_err(|e| { + msg!("TopologyCreate: invalid name '{}': {}", name, e); + ProgramError::from(e) + })?; // Validate and verify topology PDA. The account is still empty here // (we're about to create it), so we cannot use validate_program_account! diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs index 0c1df29833..ea788a6be6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -1,7 +1,34 @@ -use crate::{error::Validate, state::accounttype::AccountType}; +use crate::{ + error::{DoubleZeroError, Validate}, + state::accounttype::AccountType, +}; use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::{program_error::ProgramError, pubkey::Pubkey}; +pub const MAX_TOPOLOGY_NAME_LEN: usize = 32; + +/// Validate a topology name: non-empty, ≤32 bytes, ASCII uppercase +/// alphanumeric with dashes, and must start with a letter. +pub fn validate_topology_name(name: &str) -> Result<(), DoubleZeroError> { + if name.is_empty() { + return Err(DoubleZeroError::InvalidName); + } + if name.len() > MAX_TOPOLOGY_NAME_LEN { + return Err(DoubleZeroError::NameTooLong); + } + let mut chars = name.chars(); + let first = chars.next().expect("non-empty checked above"); + if !first.is_ascii_uppercase() { + return Err(DoubleZeroError::InvalidName); + } + for c in chars { + if !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') { + return Err(DoubleZeroError::InvalidName); + } + } + Ok(()) +} + #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] #[borsh(use_discriminant = true)] @@ -72,14 +99,11 @@ impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { } impl Validate for TopologyInfo { - fn validate(&self) -> Result<(), crate::error::DoubleZeroError> { + fn validate(&self) -> Result<(), DoubleZeroError> { if self.account_type != AccountType::Topology { - return Err(crate::error::DoubleZeroError::InvalidAccountType); + return Err(DoubleZeroError::InvalidAccountType); } - if self.name.len() > 32 { - return Err(crate::error::DoubleZeroError::NameTooLong); - } - Ok(()) + validate_topology_name(&self.name) } } @@ -90,3 +114,107 @@ pub struct FlexAlgoNodeSegment { pub topology: Pubkey, // TopologyInfo PDA pubkey pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_topology_name_accepts_valid_names() { + for name in [ + "UNICAST-DEFAULT", + "SHELBY", + "A", + "A1", + "A-B-C", + "A9", + "X-1-Y-2", + ] { + assert!( + validate_topology_name(name).is_ok(), + "expected '{name}' to be valid" + ); + } + // Exactly 32 chars is accepted. + let max_len = "A".repeat(MAX_TOPOLOGY_NAME_LEN); + assert!(validate_topology_name(&max_len).is_ok()); + } + + #[test] + fn validate_topology_name_rejects_empty() { + assert_eq!( + validate_topology_name(""), + Err(DoubleZeroError::InvalidName) + ); + } + + #[test] + fn validate_topology_name_rejects_too_long() { + let too_long = "A".repeat(MAX_TOPOLOGY_NAME_LEN + 1); + assert_eq!( + validate_topology_name(&too_long), + Err(DoubleZeroError::NameTooLong) + ); + } + + #[test] + fn validate_topology_name_rejects_lowercase() { + assert_eq!( + validate_topology_name("unicast-default"), + Err(DoubleZeroError::InvalidName) + ); + assert_eq!( + validate_topology_name("a"), + Err(DoubleZeroError::InvalidName) + ); + assert_eq!( + validate_topology_name("Mixed-Case"), + Err(DoubleZeroError::InvalidName) + ); + } + + #[test] + fn validate_topology_name_rejects_bad_first_char() { + assert_eq!( + validate_topology_name("1ABC"), + Err(DoubleZeroError::InvalidName) + ); + assert_eq!( + validate_topology_name("-ABC"), + Err(DoubleZeroError::InvalidName) + ); + } + + #[test] + fn validate_topology_name_rejects_disallowed_chars() { + for name in ["ABC_DEF", "ABC DEF", "ABC!", "ABC.DEF", "ABC/DEF"] { + assert_eq!( + validate_topology_name(name), + Err(DoubleZeroError::InvalidName), + "expected '{name}' to be rejected" + ); + } + } + + #[test] + fn topologyinfo_validate_delegates_to_name_validator() { + let mut info = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 0, + name: "UNICAST-DEFAULT".to_string(), + admin_group_bit: 1, + flex_algo_number: 129, + constraint: TopologyConstraint::IncludeAny, + reference_count: 0, + }; + assert!(info.validate().is_ok()); + + info.name = "unicast-default".to_string(); + assert_eq!(info.validate(), Err(DoubleZeroError::InvalidName)); + + info.name = "UNICAST-DEFAULT".to_string(); + info.account_type = AccountType::Device; + assert_eq!(info.validate(), Err(DoubleZeroError::InvalidAccountType)); + } +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fd5ceef1a6..bae4a8bca3 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -345,7 +345,7 @@ async fn test_topology_create_name_too_long_rejected() { // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 // We use a dummy pubkey for the topology PDA since the name validation fires // before the PDA check, and find_program_address panics on seeds > 32 bytes. - let long_name = "a".repeat(33); + let long_name = "A".repeat(33); let topology_pda = Pubkey::new_unique(); let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -366,21 +366,62 @@ async fn test_topology_create_name_too_long_rejected() { ) .await; - // DoubleZeroError::InvalidArgument = Custom(65) + // DoubleZeroError::NameTooLong = Custom(39) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(65), + InstructionError::Custom(39), ))) => {} - _ => panic!( - "Expected InvalidArgument error (Custom(65)), got {:?}", - result - ), + _ => panic!("Expected NameTooLong error (Custom(39)), got {:?}", result), } println!("[PASS] test_topology_create_name_too_long_rejected"); } +#[tokio::test] +async fn test_topology_create_invalid_name_rejected() { + println!("[TEST] test_topology_create_invalid_name_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // A name uppercase-normalization cannot salvage (underscore is not allowed). + let bad_name = "HAS_UNDERSCORE"; + let (topology_pda, _) = get_topology_pda(&program_id, bad_name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: bad_name.to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidName = Custom(87) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(87), + ))) => {} + _ => panic!("Expected InvalidName error (Custom(87)), got {:?}", result), + } + + println!("[PASS] test_topology_create_invalid_name_rejected"); +} + #[tokio::test] async fn test_topology_create_duplicate_rejected() { println!("[TEST] test_topology_create_duplicate_rejected"); From 1a6186e25ff5872b4b8c21fed25d187c779abf42 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Wed, 22 Apr 2026 18:37:09 +0000 Subject: [PATCH 11/11] test fix --- controlplane/doublezero-admin/src/cli/migrate.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 1c38c843b8..81c9287242 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -139,8 +139,8 @@ impl FlexAlgoMigrateCliCommand { device_pubkeys: devices_needing_backfill, }); match result { - Ok(sig) => { - writeln!(out, " backfilled: {sig}")?; + Ok(sigs) => { + writeln!(out, " backfilled in {} transaction(s)", sigs.len())?; } Err(e) => { writeln!(