From 00bde721f36dfd4b6ec15a5834cb33a94dbb6a76 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 23:37:03 -0500 Subject: [PATCH 1/2] 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/link/update.rs | 6 +- .../sdk/rs/src/commands/tenant/update.rs | 3 +- 12 files changed, 459 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 8d3826ed5..aa84e6f51 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 000000000..924f13941 --- /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 3985558be..1fab197ad 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 73f498614..253ee4b0c 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 696bebb8f..7187375c9 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 bd8ecc30a..e9e8b6bdc 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 775a156be..e5ea6785a 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,6 +154,8 @@ impl UpdateLinkCliCommand { desired_status: self.desired_status, tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, + link_topologies, + unicast_drained: self.unicast_drained, })?; writeln!(out, "Signature: {signature}",)?; @@ -286,6 +309,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topologies: None, + unicast_drained: None, })) .returning(move |_| Ok(signature)); @@ -305,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); @@ -330,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 0b1e82acb..fecd472d5 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 20d06a8e4..356c6f3ea 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 fbd8cfc83..acaeb05bd 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/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 192f6ee51..28b3fb82c 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -27,6 +27,8 @@ pub struct UpdateLinkCommand { pub desired_status: Option, pub tunnel_id: Option, pub tunnel_net: Option, + pub link_topologies: Option>, + pub unicast_drained: Option, } impl UpdateLinkCommand { @@ -100,8 +102,8 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, - link_topologies: None, - unicast_drained: None, + link_topologies: self.link_topologies.clone(), + unicast_drained: self.unicast_drained, }), accounts, ) diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index b0b511e37..97a7eb109 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 583d5a313e445a07e7a1bda507ab06a2f139cb10 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Fri, 10 Apr 2026 00:03:58 -0500 Subject: [PATCH 2/2] 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 138a94d29..0b95aa9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ All notable changes to this project will be documented in this file. - Add `link_topologies: Vec` (capped at 8) and `link_flags: u8` (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` - Telemetry - Device telemetry agent now posts `agent_version` and `agent_commit` in the `DeviceLatencySamplesHeader` when initializing new sample accounts, enabling version attribution of onchain telemetry data - Add optional TLS support to state-ingest server via `--tls-cert-file` and `--tls-key-file` flags; when set, the server listens on both HTTP (`:8080`) and HTTPS (`:8443`) simultaneously @@ -44,6 +46,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