Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
- Stop writing `InterfaceV3` from `CreateDeviceInterface` and `UpdateDeviceInterface`; `CurrentInterfaceVersion` is now `InterfaceV2`. `MigrateDeviceInterfaces` and `BackfillTopology` continue to write `InterfaceV3` since they are admin-controlled and need the `flex_algo_node_segments` field
- Add forward-compatible `NewInterface` struct in `state/interface.rs` with a `size: u16` + `version: u8` on-disk prefix, V3-shaped body, and `flex_algo_node_segments`. Older readers can use the size prefix to skip past unknown future versions in constant time. Additive only — no callers, processors, or SDKs change in this PR ([#3666](https://github.com/malbeclabs/doublezero/pull/3666))
- Append `new_interfaces: Vec<NewInterface>` to `Device` after `max_multicast_publishers`, behind a custom `BorshSerialize` that projects the on-disk legacy `interfaces` slot from `new_interfaces` (always `Interface::V2` per #3653) and writes `new_interfaces` at the end of the layout. Legacy accounts with no trailing bytes deserialize cleanly: `Device::try_from` rebuilds `new_interfaces` from the legacy enum vec via per-variant `TryFrom`. Older readers continue to parse the legacy slot at its existing offset; newer readers gain forward-compat via the trailing vec. Mutations now go through `Device::replace_interface` / `push_interface` / `remove_interface` so both vecs stay in sync; `find_interface` returns `&NewInterface` and `find_interface_legacy` is a temporary helper for unrelated callers ([#3665](https://github.com/malbeclabs/doublezero/pull/3665))
- Migrate serviceability processors (`device/interface/{create,update,activate,delete,reject,remove,unlink}`, `link/{accept,activate,closeaccount,create,delete,update}`, `topology/backfill`) to read and mutate `Device::new_interfaces` directly. `device.interfaces` is no longer touched in `processors/`, and `Device::push_interface` now takes a `NewInterface`. `BackfillTopology` no longer mirrors `flex_algo_node_segments` into the legacy in-memory `interfaces` vec — segments live only in `new_interfaces` and are intentionally dropped on the V2-projected on-disk legacy slot ([#3658](https://github.com/malbeclabs/doublezero/issues/3658))

## [v0.21.0](https://github.com/malbeclabs/doublezero/compare/client/v0.20.0...client/v0.21.0) - 2026-05-01

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ pub fn process_activate_device_interface(
let mut device: Device = Device::try_from(device_account)?;

let (idx, iface) = device
.find_interface_legacy(&value.name)
.find_interface(&value.name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;

if iface.status != InterfaceStatus::Pending && iface.status != InterfaceStatus::Unlinked {
Expand Down Expand Up @@ -148,7 +148,7 @@ pub fn process_activate_device_interface(
updated_iface.node_segment_idx = value.node_segment_idx;
}

device.replace_interface(idx, (&updated_iface).try_into()?);
device.replace_interface(idx, updated_iface);

try_acc_write(&device, device_account, payer_account, accounts)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ use crate::{
feature_flags::{is_feature_enabled, FeatureFlag},
globalstate::GlobalState,
interface::{
CurrentInterfaceVersion, InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType,
LoopbackType, RoutingMode, CYOA_DIA_INTERFACE_MTU, INTERFACE_MTU,
InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType,
NewInterface, RoutingMode, CURRENT_INTERFACE_SCHEMA_VERSION, CYOA_DIA_INTERFACE_MTU,
INTERFACE_MTU,
},
},
};
Expand Down Expand Up @@ -224,7 +225,12 @@ pub fn process_create_device_interface(
}
}

device.push_interface(CurrentInterfaceVersion {
// size is intentionally left at 0 — the NewInterface serializer derives the
// on-disk size fresh from the body bytes and ignores this field. It only
// gets populated on deserialize, from the wire prefix.
device.push_interface(NewInterface {
size: 0,
version: CURRENT_INTERFACE_SCHEMA_VERSION,
status,
name,
interface_type,
Expand All @@ -239,7 +245,8 @@ pub fn process_create_device_interface(
ip_net,
node_segment_idx,
user_tunnel_endpoint: value.user_tunnel_endpoint,
})?;
flex_algo_node_segments: vec![],
});

try_acc_write(&device, device_account, payer_account, accounts)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ pub fn process_delete_device_interface(

let mut device: Device = Device::try_from(device_account)?;

let (idx, _) = device
let (idx, iface) = device
.find_interface(&value.name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let iface = device.interfaces[idx].into_current_version();
let iface = iface.clone();

if iface.status != InterfaceStatus::Activated && iface.status != InterfaceStatus::Unlinked {
return Err(DoubleZeroError::InvalidStatus.into());
Expand Down Expand Up @@ -178,7 +178,7 @@ pub fn process_delete_device_interface(
// Legacy path: just mark as Deleting
let mut iface = iface;
iface.status = InterfaceStatus::Deleting;
device.replace_interface(idx, (&iface).try_into()?);
device.replace_interface(idx, iface);

#[cfg(test)]
msg!("Deleting interface: {} from {:?}", value.name, device);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,17 @@ pub fn process_reject_device_interface(

let mut device: Device = Device::try_from(device_account)?;

let (idx, mut iface) = device
.find_interface_legacy(&value.name)
let (idx, iface) = device
.find_interface(&value.name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;

if iface.status != InterfaceStatus::Pending {
return Err(DoubleZeroError::InvalidStatus.into());
}

let mut iface = iface.clone();
iface.status = InterfaceStatus::Rejected;
device.replace_interface(idx, (&iface).try_into()?);
device.replace_interface(idx, iface);

try_acc_write(&device, device_account, payer_account, accounts)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ pub fn process_unlink_device_interface(

let mut device: Device = Device::try_from(device_account)?;

let (idx, mut iface) = device
.find_interface_legacy(&value.name)
let (idx, iface) = device
.find_interface(&value.name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut iface = iface.clone();

if iface.status != InterfaceStatus::Activated && iface.status != InterfaceStatus::Pending {
return Err(DoubleZeroError::InvalidStatus.into());
Expand Down Expand Up @@ -111,7 +112,7 @@ pub fn process_unlink_device_interface(
if iface.interface_type == InterfaceType::Loopback {
iface.ip_net = NetworkV4::default();
}
device.replace_interface(idx, (&iface).try_into()?);
device.replace_interface(idx, iface);

try_acc_write(&device, device_account, payer_account, accounts)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub fn process_update_device_interface(
let (idx, _) = device
.find_interface(&value.name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut iface = device.interfaces[idx].into_current_version();
let mut iface = device.new_interfaces[idx].clone();

if let Some(loopback_type) = &value.loopback_type {
if *loopback_type == LoopbackType::None {
Expand Down Expand Up @@ -244,12 +244,9 @@ pub fn process_update_device_interface(
return Err(DoubleZeroError::InvalidMtu.into());
}

// until we have release V2 version for interfaces, always convert to v1
let updated_interface = iface.to_interface();
iface.validate()?;

updated_interface.validate()?;

device.replace_interface(idx, (&iface).try_into()?);
device.replace_interface(idx, iface);

try_acc_write(&device, device_account, payer_account, accounts)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ pub fn process_accept_link(
}

if !side_z_dev
.interfaces
.new_interfaces
.iter()
.any(|iface| iface.into_current_version().name == value.side_z_iface_name)
.any(|iface| iface.name == value.side_z_iface_name)
{
#[cfg(test)]
msg!("{:?}", side_z_dev);
Expand Down Expand Up @@ -152,23 +152,25 @@ pub fn process_accept_link(
}

let (idx_a, side_a_iface) = side_a_dev
.find_interface_legacy(&link.side_a_iface_name)
.find_interface(&link.side_a_iface_name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut updated_iface_a = side_a_iface.clone();

let (idx_z, side_z_iface) = side_z_dev
.find_interface_legacy(&link.side_z_iface_name)
.find_interface(&link.side_z_iface_name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut updated_iface_z = side_z_iface.clone();

if side_a_iface.status != InterfaceStatus::Unlinked
|| side_z_iface.status != InterfaceStatus::Unlinked
if updated_iface_a.status != InterfaceStatus::Unlinked
|| updated_iface_z.status != InterfaceStatus::Unlinked
{
return Err(DoubleZeroError::InvalidStatus.into());
}

if side_a_iface.interface_cyoa != InterfaceCYOA::None
|| side_a_iface.interface_dia != InterfaceDIA::None
|| side_z_iface.interface_cyoa != InterfaceCYOA::None
|| side_z_iface.interface_dia != InterfaceDIA::None
if updated_iface_a.interface_cyoa != InterfaceCYOA::None
|| updated_iface_a.interface_dia != InterfaceDIA::None
|| updated_iface_z.interface_cyoa != InterfaceCYOA::None
|| updated_iface_z.interface_dia != InterfaceDIA::None
{
return Err(DoubleZeroError::InterfaceHasEdgeAssignment.into());
}
Expand All @@ -182,21 +184,19 @@ pub fn process_accept_link(
&globalstate,
)?;

let mut updated_iface_a = side_a_iface.clone();
updated_iface_a.status = InterfaceStatus::Activated;
if updated_iface_a.ip_net == NetworkV4::default() {
updated_iface_a.ip_net =
NetworkV4::new(link.tunnel_net.nth(0).unwrap(), link.tunnel_net.prefix()).unwrap();
}
side_a_dev.replace_interface(idx_a, (&updated_iface_a).try_into()?);
side_a_dev.replace_interface(idx_a, updated_iface_a);

let mut updated_iface_z = side_z_iface.clone();
updated_iface_z.status = InterfaceStatus::Activated;
if updated_iface_z.ip_net == NetworkV4::default() {
updated_iface_z.ip_net =
NetworkV4::new(link.tunnel_net.nth(1).unwrap(), link.tunnel_net.prefix()).unwrap();
}
side_z_dev.replace_interface(idx_z, (&updated_iface_z).try_into()?);
side_z_dev.replace_interface(idx_z, updated_iface_z);

link.status = LinkStatus::Activated;
link.check_status_transition();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,23 +125,25 @@ pub fn process_activate_link(
}

let (idx_a, side_a_iface) = side_a_dev
.find_interface_legacy(&link.side_a_iface_name)
.find_interface(&link.side_a_iface_name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut updated_iface_a = side_a_iface.clone();

let (idx_z, side_z_iface) = side_z_dev
.find_interface_legacy(&link.side_z_iface_name)
.find_interface(&link.side_z_iface_name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
let mut updated_iface_z = side_z_iface.clone();

if side_a_iface.status != InterfaceStatus::Unlinked
|| side_z_iface.status != InterfaceStatus::Unlinked
if updated_iface_a.status != InterfaceStatus::Unlinked
|| updated_iface_z.status != InterfaceStatus::Unlinked
{
return Err(DoubleZeroError::InvalidStatus.into());
}

if side_a_iface.interface_cyoa != InterfaceCYOA::None
|| side_a_iface.interface_dia != InterfaceDIA::None
|| side_z_iface.interface_cyoa != InterfaceCYOA::None
|| side_z_iface.interface_dia != InterfaceDIA::None
if updated_iface_a.interface_cyoa != InterfaceCYOA::None
|| updated_iface_a.interface_dia != InterfaceDIA::None
|| updated_iface_z.interface_cyoa != InterfaceCYOA::None
|| updated_iface_z.interface_dia != InterfaceDIA::None
{
return Err(DoubleZeroError::InterfaceHasEdgeAssignment.into());
}
Expand Down Expand Up @@ -183,25 +185,23 @@ pub fn process_activate_link(
link.tunnel_net = value.tunnel_net;
}

let mut updated_iface_a = side_a_iface.clone();
updated_iface_a.status = InterfaceStatus::Activated;
// Only set ip_net from tunnel_net if the interface doesn't already have a user-provided ip_net
// (e.g. CYOA/DIA physical interfaces). Interfaces without a user value get tunnel IPs.
if updated_iface_a.ip_net == NetworkV4::default() {
updated_iface_a.ip_net =
NetworkV4::new(link.tunnel_net.nth(0).unwrap(), link.tunnel_net.prefix()).unwrap();
}
side_a_dev.replace_interface(idx_a, (&updated_iface_a).try_into()?);
side_a_dev.replace_interface(idx_a, updated_iface_a);

let mut updated_iface_z = side_z_iface.clone();
updated_iface_z.status = InterfaceStatus::Activated;
// Only set ip_net from tunnel_net if the interface doesn't already have a user-provided ip_net
// (e.g. CYOA/DIA physical interfaces). Interfaces without a user value get tunnel IPs.
if updated_iface_z.ip_net == NetworkV4::default() {
updated_iface_z.ip_net =
NetworkV4::new(link.tunnel_net.nth(1).unwrap(), link.tunnel_net.prefix()).unwrap();
}
side_z_dev.replace_interface(idx_z, (&updated_iface_z).try_into()?);
side_z_dev.replace_interface(idx_z, updated_iface_z);

//TODO: This should be changed once the Health Oracle is finalized.
//link.status = LinkStatus::Provisioning;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ pub fn process_closeaccount_link(
deallocate_id(link_ids_ext, link.tunnel_id);
}

if let Ok((idx_a, side_a_iface)) = side_a_dev.find_interface_legacy(&link.side_a_iface_name) {
if let Ok((idx_a, side_a_iface)) = side_a_dev.find_interface(&link.side_a_iface_name) {
let mut updated_iface = side_a_iface.clone();
updated_iface.status = InterfaceStatus::Unlinked;
// Preserve user-provided ip_net for CYOA/DIA physical interfaces.
Expand All @@ -188,10 +188,10 @@ pub fn process_closeaccount_link(
if !has_user_ip {
updated_iface.ip_net = NetworkV4::default();
}
side_a_dev.replace_interface(idx_a, (&updated_iface).try_into()?);
side_a_dev.replace_interface(idx_a, updated_iface);
}

if let Ok((idx_z, side_z_iface)) = side_z_dev.find_interface_legacy(&link.side_z_iface_name) {
if let Ok((idx_z, side_z_iface)) = side_z_dev.find_interface(&link.side_z_iface_name) {
let mut updated_iface = side_z_iface.clone();
updated_iface.status = InterfaceStatus::Unlinked;
// Preserve user-provided ip_net for CYOA/DIA physical interfaces.
Expand All @@ -203,7 +203,7 @@ pub fn process_closeaccount_link(
if !has_user_ip {
updated_iface.ip_net = NetworkV4::default();
}
side_z_dev.replace_interface(idx_z, (&updated_iface).try_into()?);
side_z_dev.replace_interface(idx_z, updated_iface);
}

contributor.reference_count = contributor.reference_count.saturating_sub(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,8 @@ pub fn process_create_link(
}

let side_a_iface = side_a_dev
.interfaces
.new_interfaces
.iter()
.map(|iface| iface.into_current_version())
.find(|iface| iface.name == value.side_a_iface_name)
.ok_or_else(|| {
#[cfg(test)]
Expand All @@ -171,9 +170,8 @@ pub fn process_create_link(
let side_z_iface_name = value.side_z_iface_name.clone().unwrap_or_default();
if let Some(ref z_name) = value.side_z_iface_name {
let side_z_iface = side_z_dev
.interfaces
.new_interfaces
.iter()
.map(|iface| iface.into_current_version())
.find(|iface| iface.name == *z_name)
.ok_or_else(|| {
#[cfg(test)]
Expand Down Expand Up @@ -280,7 +278,7 @@ pub fn process_create_link(

// Validate interfaces are Unlinked (required for activation)
let (idx_a, side_a_iface) = side_a_dev
.find_interface_legacy(&link.side_a_iface_name)
.find_interface(&link.side_a_iface_name)
.map_err(|_| DoubleZeroError::InterfaceNotFound)?;
if side_a_iface.status != InterfaceStatus::Unlinked {
return Err(DoubleZeroError::InvalidStatus.into());
Expand All @@ -294,12 +292,10 @@ pub fn process_create_link(
NetworkV4::new(link.tunnel_net.nth(0).unwrap(), link.tunnel_net.prefix())
.unwrap();
}
side_a_dev.replace_interface(idx_a, (&updated_iface_a).try_into()?);
side_a_dev.replace_interface(idx_a, updated_iface_a);

// Set side Z interface to Activated with IP from tunnel_net
if let Ok((idx_z, side_z_iface)) =
side_z_dev.find_interface_legacy(&link.side_z_iface_name)
{
if let Ok((idx_z, side_z_iface)) = side_z_dev.find_interface(&link.side_z_iface_name) {
if side_z_iface.status != InterfaceStatus::Unlinked {
return Err(DoubleZeroError::InvalidStatus.into());
}
Expand All @@ -310,7 +306,7 @@ pub fn process_create_link(
NetworkV4::new(link.tunnel_net.nth(1).unwrap(), link.tunnel_net.prefix())
.unwrap();
}
side_z_dev.replace_interface(idx_z, (&updated_iface_z).try_into()?);
side_z_dev.replace_interface(idx_z, updated_iface_z);
}

link.status = LinkStatus::Activated;
Expand Down
Loading
Loading