diff --git a/CHANGELOG.md b/CHANGELOG.md index d96331debb..d3f3426402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project will be documented in this file. ### Changes +- Smartcontract + - Rename the `BackfillTopology` instruction to `AssignTopologyNodeSegments` across the program, CLI, and Rust SDK; the instruction discriminant (110) and on-disk semantics are unchanged ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - Extend `CreateDeviceInterface` with optional trailing topology PDA accounts (`topology_count: u8`); for Vpnv4 loopbacks under onchain allocation the processor allocates a `FlexAlgoNodeSegment` per topology atomically with interface creation, so newly-provisioned devices no longer need a separate `AssignTopologyNodeSegments` step. The CLI/SDK auto-discover existing topologies and pass them. Topology accounts are validated by program-owner and by first-byte `AccountType::Topology` ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - Extend `UpdateDeviceInterface` with `update_topologies: bool` + `topology_count: u8` to reconcile flex-algo node segments against a desired topology set on a Vpnv4 loopback (deallocate removed, allocate added, preserve unchanged); contributor owner or foundation allowlist can call it ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - `DeleteDeviceInterface` now deallocates flex-algo node segments alongside the loopback's base SR ID under onchain deallocation ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) +- Controller + - Stamp the default `UNICAST-DEFAULT` topology color on tunnels for users whose access pass has no tenant; previously the color was only resolved inside the tenant lookup branch, leaving tenantless users (the majority on mainnet/testnet) with no color community ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) +- CLI + - `doublezero device interface get` displays `flex_algo_node_segments` as `topology_name:sr_id` rows, falling back to a truncated pubkey when the topology cannot be looked up ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) - Smartcontract - Canonicalize the forward-compatible Device interface names: rename the new struct from `NewInterface` to `Interface`, the legacy enum from `Interface` to `InterfaceDeprecated`, the `Device::new_interfaces` field to `Device::interfaces`, and the legacy `Device::interfaces` field to `Device::deprecated_interfaces`. On-disk format and behavior are unchanged — Borsh is positional, so the regenerated device fixture is byte-identical. Mechanical rename across the serviceability program, processors, CLI, sentinel, client, controlplane admin, and the Rust SDK. The activator/ crate was already deleted in an earlier change so the rename does not touch it ([#3663](https://github.com/malbeclabs/doublezero/issues/3663)) - Stop reading `Device::deprecated_interfaces` outside of backward-compat tests. The `delete_device` processor and `Device::validate` now check the canonical `Device::interfaces` vec; the legacy slot is treated as wire-format-only state populated by the deserializer and projected by the serializer. CLI, processor, and Rust SDK construction sites use `..Default::default()` to avoid initializing the legacy field. The unused `UpdateDeviceCommand::deprecated_interfaces` SDK command field is removed. `Device::deprecated_interfaces` itself is retained for backward-compat fixture tests that verify legacy-slot decoding ([#3663](https://github.com/malbeclabs/doublezero/issues/3663)) diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 826547cbb0..58f86086af 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -6,7 +6,7 @@ use doublezero_cli::{ wan_create::*, }, topology::{ - backfill::BackfillTopologyCliCommand, clear::ClearTopologyCliCommand, + assign_node_segments::AssignTopologyNodeSegmentsCliCommand, clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, }, @@ -79,8 +79,8 @@ pub enum TopologyCommands { Delete(DeleteTopologyCliCommand), /// Clear a topology from links Clear(ClearTopologyCliCommand), - /// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks - Backfill(BackfillTopologyCliCommand), + /// Assign FlexAlgoNodeSegment entries on Vpnv4 loopbacks + AssignNodeSegments(AssignTopologyNodeSegmentsCliCommand), /// List all topologies List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index a3d67957cb..06a3b22518 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -271,7 +271,7 @@ async fn main() -> eyre::Result<()> { TopologyCommands::Create(args) => args.execute(&client, &mut handle), TopologyCommands::Delete(args) => args.execute(&client, &mut handle), TopologyCommands::Clear(args) => args.execute(&client, &mut handle), - TopologyCommands::Backfill(args) => args.execute(&client, &mut handle), + TopologyCommands::AssignNodeSegments(args) => args.execute(&client, &mut handle), TopologyCommands::List(args) => args.execute(&client, &mut handle), }, }, diff --git a/controlplane/controller/internal/controller/server.go b/controlplane/controller/internal/controller/server.go index 91ac4c4e5b..2e4786f350 100644 --- a/controlplane/controller/internal/controller/server.go +++ b/controlplane/controller/internal/controller/server.go @@ -576,6 +576,8 @@ func (c *Controller) updateStateCache(ctx context.Context) error { if c.featuresConfig != nil && c.featuresConfig.Features.FlexAlgo.Enabled { tunnel.TenantTopologyColors = resolveTenantColors(tenant.IncludeTopologies, cache.Topologies) } + } else if c.featuresConfig != nil && c.featuresConfig.Features.FlexAlgo.Enabled { + tunnel.TenantTopologyColors = resolveTenantColors(nil, cache.Topologies) } } diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 29a0d058be..b8f5e993b9 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -3,7 +3,9 @@ use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, link::{list::ListLinkCommand, update::UpdateLinkCommand}, - topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, + topology::{ + assign_node_segments::AssignTopologyNodeSegmentsCommand, list::ListTopologyCommand, + }, }; use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; use solana_sdk::pubkey::Pubkey; @@ -133,10 +135,11 @@ impl FlexAlgoMigrateCliCommand { )?; if !self.dry_run { - let result = client.backfill_topology(BackfillTopologyCommand { - name: topology.name.clone(), - device_pubkeys: devices_needing_backfill, - }); + let result = + client.assign_topology_node_segments(AssignTopologyNodeSegmentsCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); match result { Ok(sigs) => { writeln!(out, " backfilled in {} transaction(s)", sigs.len())?; diff --git a/smartcontract/cli/src/device/interface/create.rs b/smartcontract/cli/src/device/interface/create.rs index b1e5f421af..594d315a4d 100644 --- a/smartcontract/cli/src/device/interface/create.rs +++ b/smartcontract/cli/src/device/interface/create.rs @@ -7,8 +7,12 @@ use crate::{ }; use clap::Args; use doublezero_program_common::{types::network_v4::NetworkV4, validate_iface}; -use doublezero_sdk::commands::device::{ - get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand, list::ListDeviceCommand, +use doublezero_sdk::commands::{ + device::{ + get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand, + list::ListDeviceCommand, + }, + topology::list::ListTopologyCommand, }; use std::io::Write; @@ -114,11 +118,21 @@ impl CreateDeviceInterfaceCliCommand { } } + // For Vpnv4 loopbacks, discover existing topologies so the onchain program + // can assign FlexAlgoNodeSegment entries atomically during creation. + let loopback_type = self.loopback_type.map(|lt| lt.into()).unwrap_or_default(); + let topology_names = if loopback_type == doublezero_sdk::LoopbackType::Vpnv4 { + let topologies = client.list_topology(ListTopologyCommand)?; + topologies.values().map(|t| t.name.clone()).collect() + } else { + vec![] + }; + let (signature, _) = client.create_device_interface(CreateDeviceInterfaceCommand { pubkey: device_pk, name: self.name.clone(), ip_net: self.ip_net, - loopback_type: self.loopback_type.map(|lt| lt.into()).unwrap_or_default(), + loopback_type, interface_cyoa: self.interface_cyoa.map(|ic| ic.into()).unwrap_or_default(), interface_dia: self.interface_dia.map(|id| id.into()).unwrap_or_default(), bandwidth: self.bandwidth, @@ -127,6 +141,7 @@ impl CreateDeviceInterfaceCliCommand { routing_mode: self.routing_mode.into(), vlan_id: self.vlan_id, user_tunnel_endpoint: self.user_tunnel_endpoint.unwrap_or(false), + topology_names, })?; writeln!(out, "Signature: {signature}")?; @@ -365,6 +380,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 20, user_tunnel_endpoint: false, + topology_names: vec![], })) .times(1) .returning(move |_| Ok((signature, device1_pubkey))); diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 8cbbfc4566..aa524718e5 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -1,7 +1,9 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::validate_iface; -use doublezero_sdk::commands::device::get::GetDeviceCommand; +use doublezero_sdk::commands::{ + device::get::GetDeviceCommand, topology::list::ListTopologyCommand, +}; use serde::Serialize; use std::io::Write; use tabled::Tabled; @@ -33,6 +35,7 @@ struct InterfaceDisplay { pub ip_net: String, pub node_segment_idx: u16, pub user_tunnel_endpoint: bool, + pub flex_algo_node_segments: String, pub device_pk: String, } @@ -48,6 +51,32 @@ impl GetDeviceInterfaceCliCommand { .find(|i| i.name.to_lowercase() == self.name.to_lowercase()) .ok_or_else(|| eyre::eyre!("Interface '{}' not found", self.name))?; + // Resolve flex-algo topology PDAs to names. Cache the topology map up + // front so we don't run `list_topology` once per segment; on lookup + // miss (e.g. the topology was deleted), fall back to a truncated pubkey. + let flex_algo_node_segments = if interface.flex_algo_node_segments.is_empty() { + String::new() + } else { + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); + interface + .flex_algo_node_segments + .iter() + .map(|seg| { + let label = topology_map + .get(&seg.topology) + .map(|t| t.name.clone()) + .unwrap_or_else(|| { + let s = seg.topology.to_string(); + format!("{}…", &s[..8.min(s.len())]) + }); + format!("{}:{}", label, seg.node_segment_idx) + }) + .collect::>() + .join("\n") + }; + let display = InterfaceDisplay { name: interface.name.clone(), status: interface.status.to_string(), @@ -61,6 +90,7 @@ impl GetDeviceInterfaceCliCommand { ip_net: interface.ip_net.to_string(), node_segment_idx: interface.node_segment_idx, user_tunnel_endpoint: interface.user_tunnel_endpoint, + flex_algo_node_segments, device_pk: device_pk.to_string(), }; @@ -71,8 +101,15 @@ impl GetDeviceInterfaceCliCommand { let headers = InterfaceDisplay::headers(); let fields = display.fields(); let max_len = headers.iter().map(|h| h.len()).max().unwrap_or(0); + let blank = String::new(); for (header, value) in headers.iter().zip(fields.iter()) { - writeln!(out, " {header:, + /// Reconcile flex-algo topologies on a Vpnv4 loopback. Omit to leave the + /// existing set untouched. Pass an empty value (`--topologies ""`) to + /// clear all flex-algo segments. Pass a comma-separated list of topology + /// names (or repeat the flag) to set the desired set; entries that aren't + /// in the new set have their segment routing IDs deallocated, additions + /// get freshly allocated IDs, and unchanged entries keep their IDs. + #[arg(long, value_delimiter = ',', num_args = 0..)] + pub topologies: Option>, /// Wait for the device interface to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -163,6 +171,16 @@ impl UpdateDeviceInterfaceCliCommand { .transpose() .map_err(|e| eyre::eyre!("Invalid status: {}", e))?; + // clap's `num_args = 0..` produces Some(vec!["", ...]) for `--topologies ""`, + // so strip empty/whitespace-only entries. + let topology_names = self.topologies.as_ref().map(|names| { + names + .iter() + .filter(|n| !n.trim().is_empty()) + .map(|n| n.trim().to_string()) + .collect::>() + }); + let signature = client.update_device_interface(UpdateDeviceInterfaceCommand { pubkey: device_pk, name: self.name.clone(), @@ -178,6 +196,7 @@ impl UpdateDeviceInterfaceCliCommand { status: parsed_status, ip_net: parsed_ip_net, node_segment_idx: self.node_segment_idx, + topology_names, })?; writeln!(out, "Signature: {signature}")?; @@ -310,6 +329,7 @@ mod tests { status: Some(InterfaceStatus::Activated), ip_net: Some("10.0.1.1/24".parse().unwrap()), node_segment_idx: None, + topology_names: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -331,6 +351,7 @@ mod tests { status: Some(InterfaceStatus::Activated.to_string()), ip_net: Some("10.0.1.1/24".to_string()), node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); @@ -470,6 +491,7 @@ mod tests { status: None, ip_net: Some("185.189.47.80/32".to_string()), node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); @@ -566,6 +588,7 @@ mod tests { status: None, ip_net: None, node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index d13a343b68..47e7b6fdd1 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -102,7 +102,7 @@ use doublezero_sdk::{ update_payment_status::UpdatePaymentStatusCommand, }, topology::{ - backfill::BackfillTopologyCommand, + assign_node_segments::AssignTopologyNodeSegmentsCommand, clear::ClearTopologyCommand, create::{CreateTopologyCommand, CreateTopologyResult}, delete::DeleteTopologyCommand, @@ -355,7 +355,10 @@ pub trait CliCommand { fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result; fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result>; - fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result>; + fn assign_topology_node_segments( + &self, + cmd: AssignTopologyNodeSegmentsCommand, + ) -> eyre::Result>; fn list_topology( &self, cmd: ListTopologyCommand, @@ -841,7 +844,10 @@ impl CliCommand for CliCommandImpl<'_> { fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result> { cmd.execute(self.client) } - fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result> { + fn assign_topology_node_segments( + &self, + cmd: AssignTopologyNodeSegmentsCommand, + ) -> eyre::Result> { cmd.execute(self.client) } fn list_topology( diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/assign_node_segments.rs similarity index 65% rename from smartcontract/cli/src/topology/backfill.rs rename to smartcontract/cli/src/topology/assign_node_segments.rs index a35eb7a045..6c04bbfa07 100644 --- a/smartcontract/cli/src/topology/backfill.rs +++ b/smartcontract/cli/src/topology/assign_node_segments.rs @@ -3,40 +3,38 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, }; use clap::Args; -use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; +use doublezero_sdk::commands::topology::assign_node_segments::AssignTopologyNodeSegmentsCommand; use solana_sdk::pubkey::Pubkey; use std::io::Write; #[derive(Args, Debug)] -pub struct BackfillTopologyCliCommand { - /// Name of the topology to backfill +pub struct AssignTopologyNodeSegmentsCliCommand { + /// Name of the topology to assign node segments for #[arg(long)] pub name: String, - /// Device account pubkeys to backfill (one or more) + /// Device account pubkeys (one or more) #[arg(long = "device", value_name = "PUBKEY")] pub device_pubkeys: Vec, } -impl BackfillTopologyCliCommand { +impl AssignTopologyNodeSegmentsCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; if self.device_pubkeys.is_empty() { - return Err(eyre::eyre!( - "at least one --device pubkey is required for backfill" - )); + return Err(eyre::eyre!("at least one --device pubkey is required")); } let name = self.name.to_uppercase(); - let sigs = client.backfill_topology(BackfillTopologyCommand { + let sigs = client.assign_topology_node_segments(AssignTopologyNodeSegmentsCommand { name: name.clone(), device_pubkeys: self.device_pubkeys, })?; writeln!( out, - "Backfilled topology '{}' across {} transaction(s).", + "Assigned node segments for topology '{}' across {} transaction(s).", name, sigs.len() )?; @@ -49,25 +47,25 @@ impl BackfillTopologyCliCommand { mod tests { use super::*; use crate::doublezerocommand::MockCliCommand; - use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; + use doublezero_sdk::commands::topology::assign_node_segments::AssignTopologyNodeSegmentsCommand; use mockall::predicate::eq; use solana_sdk::{pubkey::Pubkey, signature::Signature}; use std::io::Cursor; #[test] - fn test_backfill_topology_execute_success() { + fn test_assign_topology_node_segments_execute_success() { let mut mock = MockCliCommand::new(); let device1 = Pubkey::new_unique(); mock.expect_check_requirements().returning(|_| Ok(())); - mock.expect_backfill_topology() - .with(eq(BackfillTopologyCommand { + mock.expect_assign_topology_node_segments() + .with(eq(AssignTopologyNodeSegmentsCommand { name: "UNICAST-DEFAULT".to_string(), device_pubkeys: vec![device1], })) .returning(|_| Ok(vec![Signature::new_unique()])); - let cmd = BackfillTopologyCliCommand { + let cmd = AssignTopologyNodeSegmentsCliCommand { name: "unicast-default".to_string(), device_pubkeys: vec![device1], }; @@ -75,15 +73,17 @@ 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( + "Assigned node segments for topology 'UNICAST-DEFAULT' across 1 transaction(s)." + )); } #[test] - fn test_backfill_topology_requires_at_least_one_device() { + fn test_assign_topology_node_segments_requires_at_least_one_device() { let mut mock = MockCliCommand::new(); mock.expect_check_requirements().returning(|_| Ok(())); - let cmd = BackfillTopologyCliCommand { + let cmd = AssignTopologyNodeSegmentsCliCommand { name: "unicast-default".to_string(), device_pubkeys: vec![], }; diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs index 64a97eba37..21035546a4 100644 --- a/smartcontract/cli/src/topology/mod.rs +++ b/smartcontract/cli/src/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index c9ea78cadf..7d0a146412 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -98,8 +98,9 @@ use crate::{ update::process_update_tenant, update_payment_status::process_update_payment_status, }, topology::{ - backfill::process_topology_backfill, clear::process_topology_clear, - create::process_topology_create, delete::process_topology_delete, + assign_node_segments::process_assign_topology_node_segments, + clear::process_topology_clear, create::process_topology_create, + delete::process_topology_delete, }, user::{ activate::process_activate_user, ban::process_ban_user, @@ -444,8 +445,8 @@ pub fn process_instruction( DoubleZeroInstruction::ClearTopology(value) => { process_topology_clear(program_id, accounts, &value)? } - DoubleZeroInstruction::BackfillTopology(value) => { - process_topology_backfill(program_id, accounts, &value)? + DoubleZeroInstruction::AssignTopologyNodeSegments(value) => { + process_assign_topology_node_segments(program_id, accounts, &value)? } DoubleZeroInstruction::Deprecated111() => (), }; diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 3a6fe893f7..3ef454866d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -81,8 +81,8 @@ use crate::processors::{ update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, topology::{ - backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, - delete::TopologyDeleteArgs, + assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, + create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, @@ -229,10 +229,10 @@ pub enum DoubleZeroInstruction { DeleteIndex(IndexDeleteArgs), // variant 105 SetUserBGPStatus(SetUserBGPStatusArgs), // variant 106 - CreateTopology(TopologyCreateArgs), // variant 107 - DeleteTopology(TopologyDeleteArgs), // variant 108 - ClearTopology(TopologyClearArgs), // variant 109 - BackfillTopology(TopologyBackfillArgs), // variant 110 + CreateTopology(TopologyCreateArgs), // variant 107 + DeleteTopology(TopologyDeleteArgs), // variant 108 + ClearTopology(TopologyClearArgs), // variant 109 + AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs), // variant 110 Deprecated111(), // variant 111, (was MigrateDeviceInterfaces) } @@ -374,7 +374,7 @@ impl DoubleZeroInstruction { 107 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), 108 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), 109 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), - 110 => Ok(Self::BackfillTopology(TopologyBackfillArgs::try_from(rest).unwrap())), + 110 => Ok(Self::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs::try_from(rest).unwrap())), 111 => Ok(Self::Deprecated111()), _ => Err(ProgramError::InvalidInstructionData), @@ -518,8 +518,8 @@ impl DoubleZeroInstruction { Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 - Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 - Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 + Self::AssignTopologyNodeSegments(_) => "AssignTopologyNodeSegments".to_string(), // variant 110 + Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 } } @@ -654,7 +654,7 @@ impl DoubleZeroInstruction { Self::CreateTopology(args) => format!("{args:?}"), // variant 107 Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 Self::ClearTopology(args) => format!("{args:?}"), // variant 109 - Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 + Self::AssignTopologyNodeSegments(args) => format!("{args:?}"), // variant 110 Self::Deprecated111() => String::new(), // variant 111 } } @@ -1204,6 +1204,7 @@ mod tests { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), "CreateDeviceInterface", ); @@ -1235,6 +1236,8 @@ mod tests { ip_net: Some("10.0.0.0/3".parse().unwrap()), node_segment_idx: Some(1), status: None, + topology_count: 0, + update_topologies: false, }), "UpdateDeviceInterface", ); @@ -1376,10 +1379,10 @@ mod tests { "ClearTopology", ); test_instruction( - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), - "BackfillTopology", + "AssignTopologyNodeSegments", ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs index 7488559b4a..ab42a95fcc 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -17,6 +17,7 @@ use crate::{ Interface, InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, CURRENT_INTERFACE_SCHEMA_VERSION, CYOA_DIA_INTERFACE_MTU, INTERFACE_MTU, }, + topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -48,6 +49,11 @@ pub struct DeviceInterfaceCreateArgs { /// Performs atomic create+allocate+activate in a single transaction. #[incremental(default = false)] pub use_onchain_allocation: bool, + /// Number of topology PDA accounts appended after segment_routing_ids. + /// For each topology, the processor allocates a FlexAlgoNodeSegment on + /// Vpnv4 loopbacks. Zero means no topologies (backward compatible). + #[incremental(default = 0)] + pub topology_count: u8, } impl fmt::Debug for DeviceInterfaceCreateArgs { @@ -56,7 +62,7 @@ impl fmt::Debug for DeviceInterfaceCreateArgs { f, "name: {}, loopback_type: {}, vlan_id: {}, ip_net: {:?}, user_tunnel_endpoint: {}, \ interface_cyoa: {:?}, interface_dia: {:?}, bandwidth: {}, cir: {}, mtu: {}, routing_mode: {:?}, \ -use_onchain_allocation: {}", +use_onchain_allocation: {}, topology_count: {}", self.name, self.loopback_type, self.vlan_id, @@ -69,6 +75,7 @@ use_onchain_allocation: {}", self.mtu, self.routing_mode, self.use_onchain_allocation, + self.topology_count, ) } } @@ -86,7 +93,7 @@ pub fn process_create_device_interface( // Optional: ResourceExtension accounts for onchain allocation (before payer) // Account layout WITH ResourceExtension (use_onchain_allocation = true): - // [device, contributor, globalstate, device_tunnel_block, segment_routing_ids, payer, system] + // [device, contributor, globalstate, device_tunnel_block, segment_routing_ids, topology_0..N, payer, system] // Account layout WITHOUT (legacy, use_onchain_allocation = false): // [device, contributor, globalstate, payer, system] let resource_accounts = if value.use_onchain_allocation { @@ -97,6 +104,12 @@ pub fn process_create_device_interface( None }; + // Read topology PDA accounts (optional, for Vpnv4 loopback flex-algo assignment) + let mut topology_accounts = Vec::new(); + for _ in 0..value.topology_count { + topology_accounts.push(next_account_info(accounts_iter)?); + } + let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -177,6 +190,7 @@ pub fn process_create_device_interface( let mut status = InterfaceStatus::Pending; let mut ip_net = value.ip_net.unwrap_or_default(); let mut node_segment_idx: u16 = 0; + let mut flex_algo_node_segments = Vec::new(); // Atomic create+allocate+activate if onchain allocation is enabled if let Some((device_tunnel_block_ext, segment_routing_ids_ext)) = resource_accounts { @@ -215,6 +229,26 @@ pub fn process_create_device_interface( // Allocate segment routing ID for Vpnv4 loopbacks if value.loopback_type == LoopbackType::Vpnv4 { node_segment_idx = allocate_id(segment_routing_ids_ext)?; + + // Allocate a flex-algo node segment for each topology + for topo_account in &topology_accounts { + assert_eq!( + topo_account.owner, program_id, + "Invalid Topology Account Owner" + ); + assert!(!topo_account.data_is_empty(), "Topology account is empty"); + let topo_type = AccountType::from(topo_account.try_borrow_data()?[0]); + assert_eq!( + topo_type, + AccountType::Topology, + "Invalid Topology Account Type" + ); + let topo_segment_idx = allocate_id(segment_routing_ids_ext)?; + flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topo_account.key, + node_segment_idx: topo_segment_idx, + }); + } } status = InterfaceStatus::Activated; @@ -244,7 +278,7 @@ pub fn process_create_device_interface( ip_net, node_segment_idx, user_tunnel_endpoint: value.user_tunnel_endpoint, - flex_algo_node_segments: vec![], + flex_algo_node_segments, }); try_acc_write(&device, device_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs index 68b6f46102..fc31a95fbc 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs @@ -163,6 +163,11 @@ pub fn process_delete_device_interface( // Deallocate node_segment_idx if it was allocated (only for Vpnv4 loopbacks) if iface.loopback_type == LoopbackType::Vpnv4 && iface.node_segment_idx != 0 { deallocate_id(segment_routing_ids_ext, iface.node_segment_idx); + + // Deallocate flex-algo node segments allocated alongside the loopback + for entry in &iface.flex_algo_node_segments { + deallocate_id(segment_routing_ids_ext, entry.node_segment_idx); + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs index f017f5a441..961073d309 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs @@ -4,7 +4,7 @@ use crate::{ helper::format_option_displayable, pda::get_resource_extension_pda, processors::{ - resource::{allocate_specific_id, deallocate_id}, + resource::{allocate_id, allocate_specific_id, deallocate_id}, validation::validate_program_account, }, resource::ResourceType, @@ -19,6 +19,7 @@ use crate::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, CYOA_DIA_INTERFACE_MTU, INTERFACE_MTU, }, + topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -32,6 +33,7 @@ use solana_program::{ entrypoint::ProgramResult, pubkey::Pubkey, }; +use std::collections::HashSet; #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] pub struct DeviceInterfaceUpdateArgs { @@ -48,6 +50,15 @@ pub struct DeviceInterfaceUpdateArgs { pub cir: Option, pub mtu: Option, pub routing_mode: Option, + /// Number of topology PDA accounts appended after segment_routing_ids. + /// Only consumed when update_topologies is true. + #[incremental(default = 0)] + pub topology_count: u8, + /// When true, the variadic topology accounts represent the desired set of + /// flex-algo topologies; the processor reconciles flex_algo_node_segments + /// (deallocate removed, allocate added). Only valid on Vpnv4 loopbacks. + #[incremental(default = false)] + pub update_topologies: bool, } impl fmt::Debug for DeviceInterfaceUpdateArgs { @@ -56,7 +67,7 @@ impl fmt::Debug for DeviceInterfaceUpdateArgs { f, "name: {}, loopback_type: {}, vlan_id: {}, user_tunnel_endpoint: {}, status: {}, \ ip_net: {}, node_segment_idx: {}, interface_cyoa: {}, interface_dia: {}, bandwidth: {}, \ -cir: {}, mtu: {}, routing_mode: {}", +cir: {}, mtu: {}, routing_mode: {}, topology_count: {}, update_topologies: {}", self.name, format_option!(self.loopback_type), format_option!(self.vlan_id), @@ -70,6 +81,8 @@ cir: {}, mtu: {}, routing_mode: {}", format_option!(self.cir), format_option!(self.mtu), format_option!(self.routing_mode), + self.topology_count, + self.update_topologies, ) } } @@ -85,18 +98,32 @@ pub fn process_update_device_interface( let contributor_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Optional: SegmentRoutingIds resource extension account (when node_segment_idx - // is being updated with onchain allocation enabled). - // Account layout WITH onchain allocation (node_segment_idx is Some): + // Optional: SegmentRoutingIds resource extension account, present when + // node_segment_idx is being updated under onchain allocation, OR when + // reconciling flex-algo topologies. + // Account layout when reconciling topologies: + // [device, contributor, globalstate, segment_routing_ids_ext, topo_0..N, payer, system] + // Account layout for node_segment_idx under onchain allocation: // [device, contributor, globalstate, segment_routing_ids_ext, payer, system] // Account layout WITHOUT (legacy): // [device, contributor, globalstate, payer, system] - let segment_routing_ids_ext = if accounts.len() > 5 { + // + // The presence of update_topologies forces seg_ext consumption; otherwise + // fall back to the legacy account-count heuristic so callers that set + // node_segment_idx without onchain allocation enabled still work. + let segment_routing_ids_ext = if value.update_topologies || accounts.len() > 5 { Some(next_account_info(accounts_iter)?) } else { None }; + let mut topology_accounts = Vec::new(); + if value.update_topologies { + for _ in 0..value.topology_count { + topology_accounts.push(next_account_info(accounts_iter)?); + } + } + let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -226,6 +253,77 @@ pub fn process_update_device_interface( iface.node_segment_idx = node_segment_idx; } + // Reconcile flex-algo node segments against the desired topology set. + // Existing entries whose topology is still in the set keep their SR ID; entries + // for removed topologies have their SR ID deallocated; new topologies get a + // freshly allocated SR ID. + if value.update_topologies { + if contributor.owner != *payer_account.key + && !globalstate.foundation_allowlist.contains(payer_account.key) + { + return Err(DoubleZeroError::NotAllowed.into()); + } + + if iface.loopback_type != LoopbackType::Vpnv4 { + return Err(DoubleZeroError::InvalidArgument.into()); + } + + if !is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation) { + return Err(DoubleZeroError::FeatureNotEnabled.into()); + } + + let seg_ext = segment_routing_ids_ext.ok_or(DoubleZeroError::InvalidArgument)?; + + let (expected_seg_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + validate_program_account!( + seg_ext, + program_id, + writable = true, + pda = &expected_seg_pda, + "SegmentRoutingIds" + ); + + let mut desired: HashSet = HashSet::new(); + for topo_account in &topology_accounts { + assert_eq!( + topo_account.owner, program_id, + "Invalid Topology Account Owner" + ); + assert!(!topo_account.data_is_empty(), "Topology account is empty"); + let topo_type = AccountType::from(topo_account.try_borrow_data()?[0]); + assert_eq!( + topo_type, + AccountType::Topology, + "Invalid Topology Account Type" + ); + if !desired.insert(*topo_account.key) { + return Err(DoubleZeroError::InvalidArgument.into()); + } + } + + let mut kept: Vec = + Vec::with_capacity(iface.flex_algo_node_segments.len().max(desired.len())); + for entry in iface.flex_algo_node_segments.drain(..) { + if desired.contains(&entry.topology) { + kept.push(entry); + } else { + deallocate_id(seg_ext, entry.node_segment_idx); + } + } + let current: HashSet = kept.iter().map(|e| e.topology).collect(); + for topology in &desired { + if !current.contains(topology) { + let node_segment_idx = allocate_id(seg_ext)?; + kept.push(FlexAlgoNodeSegment { + topology: *topology, + node_segment_idx, + }); + } + } + iface.flex_algo_node_segments = kept; + } + // CYOA interfaces must have an ip_net — prevent setting CYOA without ip_net // or clearing ip_net from a CYOA interface via update if iface.interface_cyoa != InterfaceCYOA::None && iface.ip_net == NetworkV4::default() { diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs similarity index 81% rename from smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs rename to smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs index d4b27b4226..b1218c4720 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs @@ -19,13 +19,16 @@ use solana_program::{ }; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] -pub struct TopologyBackfillArgs { +pub struct AssignTopologyNodeSegmentsArgs { pub name: String, } -/// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an -/// already-created topology. Idempotent — skips loopbacks that already have -/// an entry for this topology. +// Keep the old name as a type alias for backward compatibility with existing serialized data. +// The on-wire format is identical (same Borsh layout, same instruction discriminant 110). +pub type TopologyBackfillArgs = AssignTopologyNodeSegmentsArgs; + +/// Assign FlexAlgoNodeSegment entries on Vpnv4 loopbacks for a topology. +/// Idempotent — skips loopbacks that already have an entry for this topology. /// /// Accounts layout: /// [0] topology PDA (readonly — must already exist) @@ -37,10 +40,10 @@ pub struct TopologyBackfillArgs { /// /// Note: payer and system_program are the last two accounts. The SDK client /// always appends them after the variable-length device list. -pub fn process_topology_backfill( +pub fn process_assign_topology_node_segments( program_id: &Pubkey, accounts: &[AccountInfo], - value: &TopologyBackfillArgs, + value: &AssignTopologyNodeSegmentsArgs, ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); @@ -52,7 +55,7 @@ pub fn process_topology_backfill( // system_program at the end, after the variable-length device list. let all_remaining: Vec<&AccountInfo> = accounts_iter.collect(); if all_remaining.len() < 2 { - msg!("TopologyBackfill: expected at least payer and system_program accounts"); + msg!("AssignTopologyNodeSegments: expected at least payer and system_program accounts"); return Err(DoubleZeroError::InvalidArgument.into()); } let payer_account = all_remaining[all_remaining.len() - 2]; @@ -60,10 +63,10 @@ pub fn process_topology_backfill( let device_accounts = &all_remaining[..all_remaining.len() - 2]; #[cfg(test)] - msg!("process_topology_backfill(name={})", value.name); + msg!("process_assign_topology_node_segments(name={})", value.name); if !payer_account.is_signer { - msg!("TopologyBackfill: payer must be a signer"); + msg!("AssignTopologyNodeSegments: payer must be a signer"); return Err(DoubleZeroError::Unauthorized.into()); } @@ -74,13 +77,16 @@ pub fn process_topology_backfill( let (expected_topology_pda, _) = get_topology_pda(program_id, &value.name); if topology_account.key != &expected_topology_pda { msg!( - "TopologyBackfill: invalid topology PDA for name '{}'", + "AssignTopologyNodeSegments: invalid topology PDA for name '{}'", value.name ); return Err(DoubleZeroError::InvalidArgument.into()); } if topology_account.data_is_empty() { - msg!("TopologyBackfill: topology '{}' does not exist", value.name); + msg!( + "AssignTopologyNodeSegments: topology '{}' does not exist", + value.name + ); return Err(DoubleZeroError::InvalidArgument.into()); } assert_eq!( @@ -110,7 +116,7 @@ pub fn process_topology_backfill( let globalstate = GlobalState::try_from(globalstate_account)?; if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyBackfill: unauthorized — foundation key required"); + msg!("AssignTopologyNodeSegments: unauthorized — foundation key required"); return Err(DoubleZeroError::Unauthorized.into()); } @@ -120,7 +126,10 @@ pub fn process_topology_backfill( // Allocate new IDs for loopbacks missing this topology's segment. for device_account in device_accounts { - msg!("BackfillTopology: processing device {}", device_account.key); + msg!( + "AssignTopologyNodeSegments: processing device {}", + device_account.key + ); let mut device = Device::try_from(&device_account.data.borrow()[..])?; let mut modified = false; // `interfaces` is the source of truth for `flex_algo_node_segments`. @@ -158,7 +167,7 @@ pub fn process_topology_backfill( } msg!( - "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + "AssignTopologyNodeSegments: '{}' — {} loopback(s) backfilled, {} already had segment", value.name, backfilled_count, skipped_count diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs index b45b525a64..595ef3f13b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 5b0b7f02e2..ee7d127a0a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -411,6 +411,7 @@ async fn test_doublezero_program() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }; execute_transaction( @@ -600,6 +601,7 @@ async fn test_doublezero_program() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }; execute_transaction( diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs index 1057a11c54..a2dd56b01b 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs @@ -11,7 +11,7 @@ use doublezero_serviceability::{ instructions::*, pda::*, processors::{ - contributor::create::ContributorCreateArgs, + contributor::{create::ContributorCreateArgs, update::ContributorUpdateArgs}, device::{ activate::DeviceActivateArgs, create::DeviceCreateArgs, @@ -23,6 +23,7 @@ use doublezero_serviceability::{ exchange::create::ExchangeCreateArgs, globalstate::setfeatureflags::SetFeatureFlagsArgs, location::create::LocationCreateArgs, + topology::create::TopologyCreateArgs, }, resource::{IdOrIp, ResourceType}, state::{ @@ -31,12 +32,14 @@ use doublezero_serviceability::{ interface::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, + topology::TopologyConstraint, }, }; use solana_program::instruction::InstructionError; use solana_program_test::*; use solana_sdk::{ - instruction::AccountMeta, pubkey::Pubkey, signer::Signer, transaction::TransactionError, + instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::TransactionError, }; mod test_helpers; @@ -301,6 +304,7 @@ async fn setup_device_with_interface( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -367,6 +371,7 @@ async fn test_create_loopback_vpnv4_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -445,6 +450,7 @@ async fn test_create_loopback_non_vpnv4_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -527,6 +533,7 @@ async fn test_create_loopback_with_onchain_allocation_honors_supplied_ip_net() { vlan_id: 0, user_tunnel_endpoint: true, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -611,6 +618,7 @@ async fn test_create_physical_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -671,6 +679,7 @@ async fn test_create_interface_backward_compat() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -727,6 +736,7 @@ async fn test_create_interface_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -799,6 +809,7 @@ async fn test_delete_loopback_with_onchain_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -897,6 +908,7 @@ async fn test_delete_physical_with_onchain_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -994,6 +1006,7 @@ async fn test_delete_interface_backward_compat() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1445,6 +1458,7 @@ async fn test_update_interface_node_segment_idx_duplicate_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1508,3 +1522,861 @@ async fn test_update_interface_node_segment_idx_duplicate_allocation() { _ => panic!("Unexpected error type: {:?}", err), } } + +// ============================================================================= +// Flex-algo node segment tests (create + delete + update reconciliation) +// ============================================================================= + +/// Helper: create a topology with the given name and return its PDA. +async fn create_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + payer: &Keypair, +) -> Pubkey { + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: 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; + topology_pda +} + +/// Test: deleting a Vpnv4 loopback that has flex-algo node segments +/// deallocates each segment's SR ID alongside the primary node_segment_idx. +#[tokio::test] +async fn test_delete_loopback_deallocates_flex_algo_segments() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey) = + setup_device(&mut banks_client, &payer, program_id, globalstate_pubkey).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Atomic create with 2 topologies: allocates primary + 2 flex-algo SR IDs. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: 2, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].clone(); + assert_eq!(iface.flex_algo_node_segments.len(), 2); + let primary = iface.node_segment_idx; + let flex_a = iface.flex_algo_node_segments[0].node_segment_idx; + let flex_b = iface.flex_algo_node_segments[1].node_segment_idx; + let resource = get_resource_extension_data(&mut banks_client, segment_routing_ids_pda) + .await + .expect("SegmentRoutingIds resource not found"); + let allocated = resource.iter_allocated(); + assert!(allocated.contains(&IdOrIp::Id(primary))); + assert!(allocated.contains(&IdOrIp::Id(flex_a))); + assert!(allocated.contains(&IdOrIp::Id(flex_b))); + + // Atomic delete should deallocate primary AND each flex-algo segment. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteDeviceInterface(DeviceInterfaceDeleteArgs { + name: "Loopback0".to_string(), + use_onchain_deallocation: true, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + assert_eq!(device.interfaces.len(), 0); + + let resource = get_resource_extension_data(&mut banks_client, segment_routing_ids_pda) + .await + .expect("SegmentRoutingIds resource not found"); + let allocated = resource.iter_allocated(); + for id in [primary, flex_a, flex_b] { + assert!( + !allocated.contains(&IdOrIp::Id(id)), + "SR id {id} should be deallocated after delete" + ); + } +} + +/// Helper: create a Vpnv4 loopback with `initial_topologies` and return +/// (device_pubkey, contributor_pubkey, segment_routing_ids_pda, device_tunnel_block_pda). +async fn setup_vpnv4_loopback_with_topologies( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, + initial_topologies: &[Pubkey], +) -> (Pubkey, Pubkey, Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (device_pubkey, contributor_pubkey) = + setup_device(banks_client, payer, program_id, globalstate_pubkey).await; + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + let mut accounts = vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ]; + for t in initial_topologies { + accounts.push(AccountMeta::new_readonly(*t, false)); + } + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: initial_topologies.len() as u8, + }), + accounts, + payer, + ) + .await; + + ( + device_pubkey, + contributor_pubkey, + segment_routing_ids_pda, + device_tunnel_block_pda, + ) +} + +async fn enable_onchain_allocation( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, +) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + payer, + ) + .await; +} + +fn update_topologies_args(name: &str, count: u8) -> DeviceInterfaceUpdateArgs { + DeviceInterfaceUpdateArgs { + name: name.to_string(), + topology_count: count, + update_topologies: true, + ..Default::default() + } +} + +fn topology_set(iface: &doublezero_serviceability::state::interface::Interface) -> Vec { + let mut s: Vec = iface + .flex_algo_node_segments + .iter() + .map(|e| e.topology) + .collect(); + s.sort(); + s +} + +/// Test: update_topologies adds a new topology while preserving the existing one's SR ID. +#[tokio::test] +async fn test_update_topologies_adds_segment() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface_before = device.interfaces[0].clone(); + let topo_a_idx = iface_before.flex_algo_node_segments[0].node_segment_idx; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface = &device.interfaces[0]; + let mut expected = vec![topo_a, topo_b]; + expected.sort(); + assert_eq!(topology_set(iface), expected); + let entry_a = iface + .flex_algo_node_segments + .iter() + .find(|e| e.topology == topo_a) + .unwrap(); + assert_eq!( + entry_a.node_segment_idx, topo_a_idx, + "topo-a SR ID should be unchanged" + ); +} + +/// Test: update_topologies removes a topology and deallocates its SR ID. +#[tokio::test] +async fn test_update_topologies_removes_segment() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let removed_idx = device.interfaces[0] + .flex_algo_node_segments + .iter() + .find(|e| e.topology == topo_b) + .unwrap() + .node_segment_idx; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + assert_eq!(topology_set(&device.interfaces[0]), vec![topo_a]); + + let resource = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap(); + assert!( + !resource.iter_allocated().contains(&IdOrIp::Id(removed_idx)), + "removed topology's SR id {removed_idx} should be deallocated" + ); +} + +/// Test: update_topologies clears all flex-algo segments when an empty set is passed. +#[tokio::test] +async fn test_update_topologies_clear() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let initial_ids: Vec = device.interfaces[0] + .flex_algo_node_segments + .iter() + .map(|e| e.node_segment_idx) + .collect(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 0)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + assert!(device.interfaces[0].flex_algo_node_segments.is_empty()); + + let resource = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap(); + let allocated = resource.iter_allocated(); + for id in initial_ids { + assert!( + !allocated.contains(&IdOrIp::Id(id)), + "all flex-algo SR ids should be deallocated after clear" + ); + } +} + +/// Test: update_topologies with the same set is a no-op (no SR id churn). +#[tokio::test] +async fn test_update_topologies_no_op() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let before = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let before_segs = before.interfaces[0].flex_algo_node_segments.clone(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let after = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let after_segs = after.interfaces[0].flex_algo_node_segments.clone(); + let mut before_sorted = before_segs; + before_sorted.sort_by_key(|e| e.topology); + let mut after_sorted = after_segs; + after_sorted.sort_by_key(|e| e.topology); + assert_eq!(before_sorted, after_sorted, "no SR id churn expected"); +} + +/// Test: update_topologies replaces the full set (swap A → B). +#[tokio::test] +async fn test_update_topologies_full_swap() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let primary_idx = device.interfaces[0].node_segment_idx; + let allocated_before = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap() + .iter_allocated() + .len(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface = &device.interfaces[0]; + assert_eq!(topology_set(iface), vec![topo_b]); + // Primary SR id should be untouched. + assert_eq!(iface.node_segment_idx, primary_idx); + // The total number of allocated SR ids should be unchanged: one removed + // (topo-a), one added (topo-b). Whether the new allocation reuses the just- + // freed slot is an implementation detail we don't pin down here. + let allocated_after = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap() + .iter_allocated() + .len(); + assert_eq!(allocated_before, allocated_after); +} + +/// Test: update_topologies on a non-Vpnv4 interface is rejected. +#[tokio::test] +async fn test_update_topologies_rejects_non_vpnv4() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let (device_pubkey, contributor_pubkey) = + setup_device(&mut banks_client, &payer, program_id, globalstate_pubkey).await; + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Create an Ipv4 (non-Vpnv4) loopback. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Ipv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ], + &payer, + ) + .await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let err = result.expect_err("expected non-Vpnv4 update_topologies to fail"); + match err { + BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + )) => { + assert_eq!(DoubleZeroError::InvalidArgument, code.into()); + } + _ => panic!("Unexpected error: {err:?}"), + } +} + +/// Test: passing the same topology twice is rejected as InvalidArgument. +#[tokio::test] +async fn test_update_topologies_rejects_duplicates() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[], + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let err = result.expect_err("expected duplicate topology rejection"); + match err { + BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + )) => { + assert_eq!(DoubleZeroError::InvalidArgument, code.into()); + } + _ => panic!("Unexpected error: {err:?}"), + } +} + +/// Test: update_topologies is authorized by the contributor's owner key, not just +/// the foundation allowlist. Reassigns contributor.owner to a new keypair (foundation +/// is required for that update), funds the new keypair, then signs UpdateDeviceInterface +/// with it. The new key is NOT in the foundation allowlist, so success here exercises +/// the contributor-owner authorization branch added by the review. +#[tokio::test] +async fn test_update_topologies_allowed_for_contributor() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + // Hand the contributor over to a new keypair so contributor.owner is no longer + // on the foundation allowlist (only foundation can do this update). + let contributor_owner = Keypair::new(); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateContributor(ContributorUpdateArgs { + code: None, + owner: Some(contributor_owner.pubkey()), + ops_manager_pk: None, + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Fund the new owner so it can pay tx fees. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let transfer_ix = solana_sdk::system_instruction::transfer( + &payer.pubkey(), + &contributor_owner.pubkey(), + 1_000_000_000, + ); + let transfer_tx = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[transfer_ix], + Some(&payer.pubkey()), + &[&payer], + recent_blockhash, + ); + banks_client.process_transaction(transfer_tx).await.unwrap(); + + // Sign update_topologies with the contributor.owner key (not foundation) — should succeed. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &contributor_owner, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let mut expected = vec![topo_a, topo_b]; + expected.sort(); + assert_eq!(topology_set(&device.interfaces[0]), expected); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index a898bf96d7..386561e7ac 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -315,6 +315,7 @@ async fn test_device_interfaces() { vlan_id: 42, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -342,6 +343,7 @@ async fn test_device_interfaces() { vlan_id: 43, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -369,6 +371,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -395,6 +398,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -422,6 +426,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -450,6 +455,7 @@ async fn test_device_interfaces() { vlan_id: 1, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -482,6 +488,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -562,6 +569,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -600,6 +608,8 @@ async fn test_device_interfaces() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1144,6 +1154,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device2_pubkey, false), @@ -1172,6 +1183,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device2_pubkey, false), @@ -1623,6 +1635,7 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1818,6 +1831,7 @@ async fn test_interface_create_invalid_mtu_cyoa() { vlan_id: 0, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index fdb00ce954..8cfcabdf73 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -275,6 +275,7 @@ async fn test_dzx_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -367,6 +368,7 @@ async fn test_dzx_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 215504b3ba..608591f063 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -184,6 +184,7 @@ async fn test_activate_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -243,6 +244,7 @@ async fn test_activate_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -573,6 +575,7 @@ async fn test_activate_link_legacy_path() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -631,6 +634,7 @@ async fn test_activate_link_legacy_path() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -902,6 +906,7 @@ async fn test_closeaccount_link_with_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -960,6 +965,7 @@ async fn test_closeaccount_link_with_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1308,6 +1314,7 @@ async fn setup_wan_link_infra( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1367,6 +1374,7 @@ async fn setup_wan_link_infra( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2760,6 +2768,7 @@ async fn test_accept_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2819,6 +2828,7 @@ async fn test_accept_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -3107,6 +3117,7 @@ async fn test_accept_link_onchain_allocation_rejects_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -3165,6 +3176,7 @@ async fn test_accept_link_onchain_allocation_rejects_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 20d970adde..fcf1977d01 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -238,6 +238,7 @@ async fn test_wan_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -369,6 +370,7 @@ async fn test_wan_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1064,6 +1066,7 @@ async fn test_wan_link_rejects_cyoa_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1157,6 +1160,7 @@ async fn test_wan_link_rejects_cyoa_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1261,6 +1265,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1289,6 +1295,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1354,6 +1362,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1412,6 +1422,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1625,6 +1637,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1716,6 +1729,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1849,6 +1863,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1885,6 +1901,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -2090,6 +2108,7 @@ async fn setup_link_env() -> ( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2178,6 +2197,7 @@ async fn setup_link_env() -> ( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2826,6 +2846,7 @@ async fn test_link_activation_succeeds_without_unicast_default() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2914,6 +2935,7 @@ async fn test_link_activation_succeeds_without_unicast_default() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs b/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs index ee1c2c4cc6..1f5dd41870 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs @@ -1466,6 +1466,7 @@ async fn create_loopback_interface( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, interface_cyoa: InterfaceCYOA::None, interface_dia: InterfaceDIA::None, bandwidth: 0, diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fb826a0199..882bd7020a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -21,8 +21,8 @@ use doublezero_serviceability::{ link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, topology::{ - backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, - delete::TopologyDeleteArgs, + assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, + create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, }, resource::ResourceType, @@ -675,6 +675,7 @@ async fn setup_wan_link( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -748,6 +749,7 @@ async fn setup_wan_link( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1663,6 +1665,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1709,7 +1712,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { ]; let mut tx = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, @@ -1738,7 +1741,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; let mut tx2 = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, @@ -1798,7 +1801,7 @@ async fn test_topology_backfill_non_foundation_rejected() { &mut banks_client, recent_blockhash, program_id, - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), vec![ @@ -1840,7 +1843,7 @@ async fn test_topology_backfill_nonexistent_topology_rejected() { &mut banks_client, recent_blockhash, program_id, - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "does-not-exist".to_string(), }), vec![ @@ -2037,6 +2040,7 @@ async fn test_topology_backfill_allocates_sr_id_from_onchain_resource() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -2088,7 +2092,7 @@ async fn test_topology_backfill_allocates_sr_id_from_onchain_resource() { ]; let mut tx = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, diff --git a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs index 5cec68dda8..f6c0328715 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -193,6 +193,7 @@ async fn setup_two_devices_with_link() -> ( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -269,6 +270,7 @@ async fn setup_two_devices_with_link() -> ( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -545,6 +547,7 @@ async fn test_unlink_from_pending() { ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs index 1e8cad2233..4f02e052cc 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs @@ -1128,6 +1128,7 @@ impl ServiceabilityProgramHelper { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pk, false), diff --git a/smartcontract/sdk/rs/src/commands/device/interface/create.rs b/smartcontract/sdk/rs/src/commands/device/interface/create.rs index 4fdd464e3a..0e63d8b3ca 100644 --- a/smartcontract/sdk/rs/src/commands/device/interface/create.rs +++ b/smartcontract/sdk/rs/src/commands/device/interface/create.rs @@ -5,7 +5,7 @@ use crate::{ use doublezero_program_common::types::network_v4::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::device::interface::create::DeviceInterfaceCreateArgs, resource::ResourceType, state::{ @@ -29,6 +29,9 @@ pub struct CreateDeviceInterfaceCommand { pub routing_mode: RoutingMode, pub vlan_id: u16, pub user_tunnel_endpoint: bool, + /// Topology names to assign flex-algo node segments for (Vpnv4 loopbacks only). + /// Empty means no topology assignment. + pub topology_names: Vec, } impl CreateDeviceInterfaceCommand { @@ -51,6 +54,7 @@ impl CreateDeviceInterfaceCommand { AccountMeta::new(globalstate_pubkey, false), ]; + let mut topology_count: u8 = 0; if use_onchain_allocation { let (device_tunnel_block_ext, _, _) = get_resource_extension_pda( &client.get_program_id(), @@ -62,6 +66,16 @@ impl CreateDeviceInterfaceCommand { ); accounts.push(AccountMeta::new(device_tunnel_block_ext, false)); accounts.push(AccountMeta::new(segment_routing_ids_ext, false)); + + // For Vpnv4 loopbacks, append topology PDAs so the onchain program + // can allocate FlexAlgoNodeSegment entries atomically. + if self.loopback_type == LoopbackType::Vpnv4 { + for name in &self.topology_names { + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), name); + accounts.push(AccountMeta::new_readonly(topology_pda, false)); + topology_count += 1; + } + } } client @@ -79,6 +93,7 @@ impl CreateDeviceInterfaceCommand { vlan_id: self.vlan_id, user_tunnel_endpoint: self.user_tunnel_endpoint, use_onchain_allocation, + topology_count, }), accounts, ) @@ -167,6 +182,7 @@ mod tests { vlan_id: 100, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }, )), predicate::eq(vec![ @@ -190,6 +206,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 100, user_tunnel_endpoint: true, + topology_names: vec![], }; let res = command.execute(&client); @@ -258,6 +275,7 @@ mod tests { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }, )), predicate::eq(vec![ @@ -283,6 +301,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, + topology_names: vec![], } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/device/interface/update.rs b/smartcontract/sdk/rs/src/commands/device/interface/update.rs index 186a6974e4..6f02bfc716 100644 --- a/smartcontract/sdk/rs/src/commands/device/interface/update.rs +++ b/smartcontract/sdk/rs/src/commands/device/interface/update.rs @@ -5,7 +5,7 @@ use crate::{ use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::device::interface::DeviceInterfaceUpdateArgs, resource::ResourceType, state::{ @@ -31,6 +31,10 @@ pub struct UpdateDeviceInterfaceCommand { pub status: Option, pub ip_net: Option, pub node_segment_idx: Option, + /// Reconcile flex-algo topology set on a Vpnv4 loopback. None leaves it + /// alone; Some(vec![]) clears all entries; Some(names) sets exactly that + /// set. Names are resolved to Topology PDAs via get_topology_pda. + pub topology_names: Option>, } impl UpdateDeviceInterfaceCommand { @@ -50,11 +54,13 @@ impl UpdateDeviceInterfaceCommand { AccountMeta::new(globalstate_pubkey, false), ]; - // Include SegmentRoutingIds resource account when updating node_segment_idx - // with onchain allocation enabled - if self.node_segment_idx.is_some() - && is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation) - { + let onchain_allocation_enabled = + is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation); + let update_topologies = self.topology_names.is_some() && onchain_allocation_enabled; + let needs_seg_ext = + (self.node_segment_idx.is_some() && onchain_allocation_enabled) || update_topologies; + + if needs_seg_ext { let (seg_routing_pda, _, _) = get_resource_extension_pda( &client.get_program_id(), ResourceType::SegmentRoutingIds, @@ -62,6 +68,15 @@ impl UpdateDeviceInterfaceCommand { accounts.push(AccountMeta::new(seg_routing_pda, false)); } + let mut topology_count: u8 = 0; + if update_topologies { + for name in self.topology_names.as_ref().unwrap() { + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), name); + accounts.push(AccountMeta::new_readonly(topology_pda, false)); + topology_count += 1; + } + } + client.execute_transaction( DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: self.name.clone(), @@ -77,6 +92,8 @@ impl UpdateDeviceInterfaceCommand { status: self.status, ip_net: self.ip_net, node_segment_idx: self.node_segment_idx, + topology_count, + update_topologies, }), accounts, ) @@ -153,18 +170,8 @@ mod tests { predicate::eq(DoubleZeroInstruction::UpdateDeviceInterface( DeviceInterfaceUpdateArgs { name: "Ethernet0".to_string(), - loopback_type: None, - bandwidth: None, - cir: None, - mtu: None, - routing_mode: None, - interface_dia: None, vlan_id: Some(42), - user_tunnel_endpoint: None, - interface_cyoa: None, - status: None, - ip_net: None, - node_segment_idx: None, + ..Default::default() }, )), predicate::eq(vec![ @@ -190,6 +197,7 @@ mod tests { ip_net: None, interface_dia: None, node_segment_idx: None, + topology_names: None, }; let res = update_command.execute(&client); @@ -274,6 +282,7 @@ mod tests { user_tunnel_endpoint: None, status: None, ip_net: None, + topology_names: None, }; let res = update_command.execute(&client); @@ -329,9 +338,101 @@ mod tests { user_tunnel_endpoint: None, status: None, ip_net: None, + topology_names: None, }; let res = update_command.execute(&client); assert!(res.is_ok()); } + + /// Test that updating topologies appends seg_routing + topology PDAs and sets the flags + #[test] + fn test_commands_device_interface_update_topologies() { + let mut client = MockDoubleZeroClient::new(); + + let program_id = Pubkey::new_unique(); + client.expect_get_program_id().returning(move || program_id); + let payer = Pubkey::new_unique(); + client.expect_get_payer().returning(move || payer); + + let (globalstate_pubkey, bump_seed) = get_globalstate_pda(&program_id); + let globalstate = GlobalState { + account_type: AccountType::GlobalState, + bump_seed, + account_index: 0, + foundation_allowlist: vec![], + _device_allowlist: vec![], + _user_allowlist: vec![], + activator_authority_pk: Pubkey::new_unique(), + sentinel_authority_pk: Pubkey::new_unique(), + contributor_airdrop_lamports: 1_000_000_000, + user_airdrop_lamports: 40_000, + health_oracle_pk: Pubkey::new_unique(), + qa_allowlist: vec![], + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + feed_authority_pk: Pubkey::default(), + }; + client + .expect_get() + .with(predicate::eq(globalstate_pubkey)) + .returning(move |_| Ok(AccountData::GlobalState(globalstate.clone()))); + + let device_pubkey = Pubkey::new_unique(); + let device = make_test_device(); + let contributor_pk = device.contributor_pk; + + client + .expect_get() + .with(predicate::eq(device_pubkey)) + .returning(move |_| Ok(AccountData::Device(device.clone()))); + + let (seg_routing_pda, _, _) = get_resource_extension_pda( + &program_id, + doublezero_serviceability::resource::ResourceType::SegmentRoutingIds, + ); + let (topo_a, _) = doublezero_serviceability::pda::get_topology_pda(&program_id, "TOPO-A"); + let (topo_b, _) = doublezero_serviceability::pda::get_topology_pda(&program_id, "TOPO-B"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::UpdateDeviceInterface( + DeviceInterfaceUpdateArgs { + name: "Loopback256".to_string(), + topology_count: 2, + update_topologies: true, + ..Default::default() + }, + )), + predicate::eq(vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pk, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = UpdateDeviceInterfaceCommand { + pubkey: device_pubkey, + name: "Loopback256".to_string(), + loopback_type: None, + interface_cyoa: None, + interface_dia: None, + bandwidth: None, + cir: None, + mtu: None, + routing_mode: None, + vlan_id: None, + user_tunnel_endpoint: None, + status: None, + ip_net: None, + node_segment_idx: None, + topology_names: Some(vec!["TOPO-A".to_string(), "TOPO-B".to_string()]), + } + .execute(&client); + assert!(res.is_ok()); + } } diff --git a/smartcontract/sdk/rs/src/commands/topology/backfill.rs b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs similarity index 85% rename from smartcontract/sdk/rs/src/commands/topology/backfill.rs rename to smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs index 23ae8ded39..4e5be0958d 100644 --- a/smartcontract/sdk/rs/src/commands/topology/backfill.rs +++ b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs @@ -2,7 +2,7 @@ use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient} use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_resource_extension_pda, get_topology_pda}, - processors::topology::backfill::TopologyBackfillArgs, + processors::topology::assign_node_segments::AssignTopologyNodeSegmentsArgs, resource::ResourceType, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; @@ -13,12 +13,12 @@ use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature} pub const BACKFILL_BATCH_SIZE: usize = 16; #[derive(Debug, PartialEq, Clone)] -pub struct BackfillTopologyCommand { +pub struct AssignTopologyNodeSegmentsCommand { pub name: String, pub device_pubkeys: Vec, } -impl BackfillTopologyCommand { +impl AssignTopologyNodeSegmentsCommand { pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result> { let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand .execute(client) @@ -42,7 +42,7 @@ impl BackfillTopologyCommand { } let sig = client.execute_transaction( - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: self.name.clone(), }), accounts, @@ -57,14 +57,16 @@ impl BackfillTopologyCommand { #[cfg(test)] mod tests { use crate::{ - commands::topology::backfill::{BackfillTopologyCommand, BACKFILL_BATCH_SIZE}, + commands::topology::assign_node_segments::{ + AssignTopologyNodeSegmentsCommand, BACKFILL_BATCH_SIZE, + }, tests::utils::create_test_client, DoubleZeroClient, }; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, - processors::topology::backfill::TopologyBackfillArgs, + processors::topology::assign_node_segments::AssignTopologyNodeSegmentsArgs, resource::ResourceType, }; use mockall::{predicate, Sequence}; @@ -74,7 +76,7 @@ mod tests { fn test_commands_topology_backfill_no_devices_sends_no_tx() { let client = create_test_client(); - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "unicast-default".to_string(), device_pubkeys: vec![], } @@ -97,8 +99,8 @@ mod tests { client .expect_execute_transaction() .with( - predicate::eq(DoubleZeroInstruction::BackfillTopology( - TopologyBackfillArgs { + predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( + AssignTopologyNodeSegmentsArgs { name: "algo128".to_string(), }, )), @@ -112,7 +114,7 @@ mod tests { ) .returning(|_, _| Ok(Signature::new_unique())); - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "algo128".to_string(), device_pubkeys: vec![device1, device2], } @@ -138,9 +140,10 @@ mod tests { AccountMeta::new_readonly(globalstate_pubkey, false), ]; - let expected_args = DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { - name: "algo128".to_string(), - }); + let expected_args = + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { + name: "algo128".to_string(), + }); let mut seq = Sequence::new(); for chunk in devices.chunks(BACKFILL_BATCH_SIZE) { @@ -159,7 +162,7 @@ mod tests { .returning(|_, _| Ok(Signature::new_unique())); } - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "algo128".to_string(), device_pubkeys: devices, } diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index 75df31d738..e2083ed27f 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -1,7 +1,7 @@ use crate::{ commands::{ device::list::ListDeviceCommand, globalstate::get::GetGlobalStateCommand, - topology::backfill::BackfillTopologyCommand, + topology::assign_node_segments::AssignTopologyNodeSegmentsCommand, }, DoubleZeroClient, }; @@ -74,7 +74,7 @@ impl CreateTopologyCommand { .collect(); device_pubkeys.sort(); - let backfill_signatures = BackfillTopologyCommand { + let backfill_signatures = AssignTopologyNodeSegmentsCommand { name: self.name.clone(), device_pubkeys, } @@ -99,7 +99,9 @@ mod tests { use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, - processors::topology::{backfill::TopologyBackfillArgs, create::TopologyCreateArgs}, + processors::topology::{ + assign_node_segments::AssignTopologyNodeSegmentsArgs, create::TopologyCreateArgs, + }, resource::ResourceType, state::{ accountdata::AccountData, @@ -228,8 +230,8 @@ mod tests { .times(1) .in_sequence(&mut seq) .with( - predicate::eq(DoubleZeroInstruction::BackfillTopology( - TopologyBackfillArgs { + predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( + AssignTopologyNodeSegmentsArgs { name: "algo128".to_string(), }, )), diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs index 01fa6fd760..43f51edd11 100644 --- a/smartcontract/sdk/rs/src/commands/topology/mod.rs +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; pub mod clear; pub mod create; pub mod delete;