use std::{
collections::{HashMap, HashSet},
net::IpAddr,
};
use super::types::{
DeclaredAddress, DeclaredLink, DeclaredLinkType, DeclaredQdisc, DeclaredQdiscType,
DeclaredRoute, DeclaredRouteType, LinkState, NetworkConfig, QdiscParent,
};
use crate::netlink::{
builder::MessageBuilder,
connection::Connection,
error::Result,
messages::{AddressMessage, LinkMessage, RouteMessage, TcMessage},
protocol::Route,
tc::{
ClsactConfig, IngressConfig, QdiscConfig,
},
types::{addr::Scope, route::RouteProtocol, route::RouteType},
};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DiffOptions {
pub purge: bool,
pub purge_tables: Vec<u32>,
}
impl DiffOptions {
pub fn purge(mut self, on: bool) -> Self {
self.purge = on;
self
}
pub fn purge_tables(mut self, tables: impl IntoIterator<Item = u32>) -> Self {
self.purge_tables.extend(tables);
self
}
pub(crate) fn purge_table_scope(&self, config: &NetworkConfig) -> HashSet<u32> {
let mut scope: HashSet<u32> = HashSet::from([254]);
scope.extend(config.routes.iter().filter_map(|r| r.table));
scope.extend(self.purge_tables.iter().copied());
scope
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[derive(Debug, Default)]
#[non_exhaustive]
#[must_use = "Diffs do nothing unless passed to `.apply()` or stringified via `Display`"]
pub struct ConfigDiff {
pub links_to_add: Vec<DeclaredLink>,
pub links_to_modify: Vec<(String, LinkChanges)>,
pub addresses_to_add: Vec<DeclaredAddress>,
pub routes_to_add: Vec<DeclaredRoute>,
pub qdiscs_to_add: Vec<DeclaredQdisc>,
pub qdiscs_to_replace: Vec<DeclaredQdisc>,
pub addresses_to_remove: Vec<DeclaredAddress>,
pub routes_to_remove: Vec<DeclaredRoute>,
}
impl ConfigDiff {
pub async fn apply(
&self,
conn: &Connection<Route>,
opts: super::apply::ApplyOptions,
) -> Result<super::apply::ApplyResult> {
super::apply::apply_diff(self, conn, opts).await
}
pub fn is_empty(&self) -> bool {
self.links_to_add.is_empty()
&& self.links_to_modify.is_empty()
&& self.addresses_to_add.is_empty()
&& self.routes_to_add.is_empty()
&& self.qdiscs_to_add.is_empty()
&& self.qdiscs_to_replace.is_empty()
&& self.addresses_to_remove.is_empty()
&& self.routes_to_remove.is_empty()
}
pub fn change_count(&self) -> usize {
self.links_to_add.len()
+ self.links_to_modify.len()
+ self.addresses_to_add.len()
+ self.routes_to_add.len()
+ self.qdiscs_to_add.len()
+ self.qdiscs_to_replace.len()
+ self.addresses_to_remove.len()
+ self.routes_to_remove.len()
}
#[deprecated(
since = "0.19.0",
note = "use `Display` via `format!(\"{}\")` or `diff.to_string()` instead — Plan 188 §2.6"
)]
pub fn summary(&self) -> String {
let mut lines = Vec::new();
for link in &self.links_to_add {
lines.push(format!(
"+ link {} ({})",
link.name,
link.link_type.kind().unwrap_or("physical")
));
}
for (name, changes) in &self.links_to_modify {
lines.push(format!("~ link {} ({})", name, changes.summary()));
}
for addr in &self.addresses_to_add {
lines.push(format!(
"+ address {}/{} on {}",
addr.address, addr.prefix_len, addr.dev
));
}
for route in &self.routes_to_add {
let via = route
.gateway
.map(|g| format!(" via {}", g))
.unwrap_or_default();
let dev = route
.dev
.as_ref()
.map(|d| format!(" dev {}", d))
.unwrap_or_default();
lines.push(format!(
"+ route {}/{}{}{}",
route.destination, route.prefix_len, via, dev
));
}
for qdisc in &self.qdiscs_to_add {
lines.push(format!(
"+ qdisc {} on {} ({:?})",
qdisc.qdisc_type.kind(),
qdisc.dev,
qdisc.parent
));
}
for qdisc in &self.qdiscs_to_replace {
lines.push(format!(
"~ qdisc {} on {} ({:?})",
qdisc.qdisc_type.kind(),
qdisc.dev,
qdisc.parent
));
}
for addr in &self.addresses_to_remove {
lines.push(format!(
"- address {}/{} on {}",
addr.address, addr.prefix_len, addr.dev
));
}
for route in &self.routes_to_remove {
let via = route
.gateway
.map(|g| format!(" via {}", g))
.unwrap_or_default();
let dev = route
.dev
.as_ref()
.map(|d| format!(" dev {}", d))
.unwrap_or_default();
lines.push(format!(
"- route {}/{}{}{}",
route.destination, route.prefix_len, via, dev
));
}
if lines.is_empty() {
"No changes needed".to_string()
} else {
lines.join("\n")
}
}
}
impl std::fmt::Display for ConfigDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[allow(deprecated)]
f.write_str(&self.summary())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[derive(Debug, Default)]
pub struct LinkChanges {
pub set_up: bool,
pub set_down: bool,
pub set_mtu: Option<u32>,
pub set_master: Option<String>,
pub unset_master: bool,
pub set_address: Option<[u8; 6]>,
}
impl LinkChanges {
pub fn is_empty(&self) -> bool {
!self.set_up
&& !self.set_down
&& self.set_mtu.is_none()
&& self.set_master.is_none()
&& !self.unset_master
&& self.set_address.is_none()
}
pub fn summary(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.set_up {
parts.push("up".to_string());
}
if self.set_down {
parts.push("down".to_string());
}
if let Some(mtu) = self.set_mtu {
parts.push(format!("mtu={}", mtu));
}
if let Some(master) = &self.set_master {
parts.push(format!("master={}", master));
}
if self.unset_master {
parts.push("nomaster".to_string());
}
if let Some(a) = self.set_address {
parts.push(format!(
"address={:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
a[0], a[1], a[2], a[3], a[4], a[5]
));
}
parts.join(", ")
}
}
impl std::fmt::Display for LinkChanges {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.summary())
}
}
pub async fn compute_diff(config: &NetworkConfig, conn: &Connection<Route>) -> Result<ConfigDiff> {
compute_diff_with_options(config, conn, &DiffOptions::default()).await
}
pub async fn compute_diff_with_options(
config: &NetworkConfig,
conn: &Connection<Route>,
opts: &DiffOptions,
) -> Result<ConfigDiff> {
let mut diff = ConfigDiff::default();
let current_links = conn.get_links().await?;
let current_addresses = conn.get_addresses().await?;
let current_routes = conn.get_routes().await?;
let current_qdiscs = conn.get_qdiscs().await?;
let link_by_name: HashMap<&str, &LinkMessage> = current_links
.iter()
.filter_map(|l| l.name.as_deref().map(|n| (n, l)))
.collect();
let ifindex_to_name: HashMap<u32, &str> = current_links
.iter()
.filter_map(|l| l.name.as_deref().map(|n| (l.ifindex(), n)))
.collect();
diff_links(config, &link_by_name, &ifindex_to_name, &mut diff);
topo_sort_links_to_add(&mut diff.links_to_add);
diff_addresses(config, ¤t_addresses, &ifindex_to_name, opts.purge, &mut diff);
diff_routes(config, ¤t_routes, &ifindex_to_name, opts, &mut diff);
diff_qdiscs(config, ¤t_qdiscs, &ifindex_to_name, &mut diff);
Ok(diff)
}
fn diff_links(
config: &NetworkConfig,
current: &HashMap<&str, &LinkMessage>,
ifindex_to_name: &HashMap<u32, &str>,
diff: &mut ConfigDiff,
) {
let _desired_names: HashSet<&str> = config.links.iter().map(|l| l.name.as_str()).collect();
for declared in &config.links {
if let Some(existing) = current.get(declared.name.as_str()) {
let changes = compute_link_changes(declared, existing, ifindex_to_name);
if !changes.is_empty() {
diff.links_to_modify.push((declared.name.clone(), changes));
}
} else {
if declared.link_type != DeclaredLinkType::Physical {
diff.links_to_add.push(declared.clone());
}
}
}
}
fn topo_sort_links_to_add(links: &mut Vec<DeclaredLink>) {
if links.len() < 2 {
return;
}
let names_in_batch: HashSet<String> =
links.iter().map(|l| l.name.clone()).collect();
fn deps_of(link: &DeclaredLink, names_in_batch: &HashSet<String>) -> Vec<String> {
let mut deps = Vec::new();
match &link.link_type {
DeclaredLinkType::Vlan { parent, .. } => deps.push(parent.clone()),
DeclaredLinkType::Macvlan { parent, .. } => deps.push(parent.clone()),
DeclaredLinkType::Vxlan {
underlay_dev: Some(dev),
..
} => deps.push(dev.clone()),
_ => {}
}
if let Some(master) = &link.master {
deps.push(master.clone());
}
deps.retain(|d| names_in_batch.contains(d));
deps
}
let mut emitted: HashSet<String> = HashSet::new();
let mut out: Vec<DeclaredLink> = Vec::with_capacity(links.len());
let mut remaining: Vec<DeclaredLink> = std::mem::take(links);
while !remaining.is_empty() {
let before = remaining.len();
let mut next_remaining = Vec::with_capacity(remaining.len());
for link in remaining.into_iter() {
let deps = deps_of(&link, &names_in_batch);
let ready = deps.iter().all(|d| emitted.contains(d));
if ready {
emitted.insert(link.name.clone());
out.push(link);
} else {
next_remaining.push(link);
}
}
if next_remaining.len() == before {
out.extend(next_remaining);
break;
}
remaining = next_remaining;
}
*links = out;
}
fn compute_link_changes(
declared: &DeclaredLink,
existing: &LinkMessage,
ifindex_to_name: &HashMap<u32, &str>,
) -> LinkChanges {
let mut changes = LinkChanges::default();
let is_admin_up = existing.is_up();
match declared.state {
LinkState::Up => {
if !is_admin_up {
changes.set_up = true;
}
}
LinkState::Down => {
if is_admin_up {
changes.set_down = true;
}
}
LinkState::Unchanged => {}
}
if let Some(desired_mtu) = declared.mtu
&& existing.mtu != Some(desired_mtu)
{
changes.set_mtu = Some(desired_mtu);
}
if let Some(desired_mac) = declared.address {
let same = existing.address().is_some_and(|have| have == desired_mac);
if !same {
changes.set_address = Some(desired_mac);
}
}
let existing_master_name: Option<&str> = existing
.master()
.and_then(|idx| ifindex_to_name.get(&idx).copied());
match (declared.master.as_deref(), existing_master_name) {
(Some(want), Some(have)) if want == have => {
}
(Some(want), _) => {
changes.set_master = Some(want.to_string());
}
(None, Some(_)) => {
changes.unset_master = true;
}
(None, None) => {
}
}
changes
}
fn diff_addresses(
config: &NetworkConfig,
current: &[AddressMessage],
ifindex_to_name: &HashMap<u32, &str>,
purge: bool,
diff: &mut ConfigDiff,
) {
let desired: HashSet<(&str, IpAddr, u8)> = config
.addresses
.iter()
.map(|a| (a.dev.as_str(), a.address, a.prefix_len))
.collect();
let current_set: HashSet<(&str, IpAddr, u8)> = current
.iter()
.filter_map(|a| {
let name = ifindex_to_name.get(&a.ifindex())?;
let addr = a.address?;
Some((*name, addr, a.prefix_len()))
})
.collect();
for declared in &config.addresses {
let key = (declared.dev.as_str(), declared.address, declared.prefix_len);
if !current_set.contains(&key) {
diff.addresses_to_add.push(declared.clone());
}
}
if !purge {
let _ = desired; return;
}
let managed_devs: HashSet<&str> = config.addresses.iter().map(|a| a.dev.as_str()).collect();
for a in current {
if a.scope() != Scope::Universe {
continue;
}
let Some(name) = ifindex_to_name.get(&a.ifindex()) else {
continue;
};
if !managed_devs.contains(name) {
continue;
}
let Some(addr) = a.address else {
continue;
};
let key = (*name, addr, a.prefix_len());
if !desired.contains(&key) {
diff.addresses_to_remove.push(DeclaredAddress {
dev: name.to_string(),
address: addr,
prefix_len: a.prefix_len(),
});
}
}
}
const IP6_RT_PRIO_USER: u32 = 1024;
fn diff_routes(
config: &NetworkConfig,
current: &[RouteMessage],
ifindex_to_name: &HashMap<u32, &str>,
opts: &DiffOptions,
diff: &mut ConfigDiff,
) {
let purge = opts.purge;
let name_to_ifindex: HashMap<&str, u32> = ifindex_to_name
.iter()
.map(|(idx, name)| (*name, *idx))
.collect();
let mut current_by_key: HashMap<(IpAddr, u8, u32), Vec<&RouteMessage>> = HashMap::new();
for r in current.iter().filter(|r| {
matches!(
r.route_type(),
RouteType::Unicast
| RouteType::Blackhole
| RouteType::Unreachable
| RouteType::Prohibit
)
}) {
let dst = r.destination.unwrap_or_else(|| {
if r.is_ipv4() {
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
}
});
current_by_key
.entry((dst, r.dst_len(), r.table_id()))
.or_default()
.push(r);
}
for declared in &config.routes {
let table = declared.table.unwrap_or(254);
let key = (declared.destination, declared.prefix_len, table);
let declared_dev_unresolved =
declared.dev.is_some() && {
let d = declared.dev.as_deref().expect("is_some");
!name_to_ifindex.contains_key(d)
};
let declared_oif = declared
.dev
.as_deref()
.and_then(|d| name_to_ifindex.get(d).copied());
let matches_existing = !declared_dev_unresolved
&& current_by_key.get(&key).is_some_and(|kernel_routes| {
kernel_routes.iter().any(|r| {
let gw_match = match (declared.gateway, r.gateway()) {
(None, None) => true,
(Some(a), Some(b)) => a == *b,
_ => false,
};
let dev_match = match (declared_oif, r.oif()) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
(None, Some(_)) => true,
(Some(_), None) => false,
};
let kernel_default_metric = if declared.destination.is_ipv6() {
IP6_RT_PRIO_USER
} else {
0
};
let metric_match =
declared.metric.unwrap_or(kernel_default_metric) == r.priority().unwrap_or(0);
gw_match && dev_match && metric_match
})
});
if !matches_existing {
diff.routes_to_add.push(declared.clone());
}
}
if !purge {
return;
}
let desired_keys: HashSet<(IpAddr, u8, u32)> = config
.routes
.iter()
.map(|r| (r.destination, r.prefix_len, r.table.unwrap_or(254)))
.collect();
let tables = opts.purge_table_scope(config);
for r in current {
if !tables.contains(&r.table_id()) {
continue;
}
if !matches!(r.protocol(), RouteProtocol::Static | RouteProtocol::Boot) {
continue;
}
let route_type = match r.route_type() {
RouteType::Unicast => DeclaredRouteType::Unicast,
RouteType::Blackhole => DeclaredRouteType::Blackhole,
RouteType::Unreachable => DeclaredRouteType::Unreachable,
RouteType::Prohibit => DeclaredRouteType::Prohibit,
_ => continue,
};
let dst = r.destination.unwrap_or_else(|| {
if r.is_ipv4() {
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
}
});
let key = (dst, r.dst_len(), r.table_id());
if desired_keys.contains(&key) {
continue;
}
let dev = r
.oif()
.and_then(|idx| ifindex_to_name.get(&idx))
.map(|n| n.to_string());
diff.routes_to_remove.push(DeclaredRoute {
destination: dst,
prefix_len: r.dst_len(),
gateway: r.gateway().copied(),
dev,
metric: r.priority(),
table: Some(r.table_id()),
route_type,
});
}
}
fn diff_qdiscs(
config: &NetworkConfig,
current: &[TcMessage],
ifindex_to_name: &HashMap<u32, &str>,
diff: &mut ConfigDiff,
) {
let mut current_root_qdisc: HashMap<&str, &TcMessage> = HashMap::new();
let mut current_ingress_qdisc: HashMap<&str, &TcMessage> = HashMap::new();
for qdisc in current {
if let Some(name) = ifindex_to_name.get(&qdisc.ifindex()) {
if qdisc.is_root() {
current_root_qdisc.insert(*name, qdisc);
} else if qdisc.is_ingress() {
current_ingress_qdisc.insert(*name, qdisc);
}
}
}
for declared in &config.qdiscs {
let current_map = match declared.effective_parent() {
QdiscParent::Root => ¤t_root_qdisc,
QdiscParent::Ingress => ¤t_ingress_qdisc,
};
if let Some(existing) = current_map.get(declared.dev.as_str()) {
let existing_kind = existing.kind().unwrap_or("");
let desired_kind = declared.qdisc_type.kind();
if existing_kind != desired_kind {
diff.qdiscs_to_replace.push(declared.clone());
} else if !qdisc_params_match(&declared.qdisc_type, existing) {
diff.qdiscs_to_replace.push(declared.clone());
}
} else {
diff.qdiscs_to_add.push(declared.clone());
}
}
}
fn qdisc_params_match(declared: &DeclaredQdiscType, existing: &TcMessage) -> bool {
use crate::netlink::tc_options::QdiscOptions;
use crate::netlink::tc_recipe_internals::{codel_round_trip_us, netem_matches};
match (declared, existing.options()) {
(DeclaredQdiscType::Netem { .. }, _) => {
let cfg = declared.netem_config().expect("matched the Netem arm");
netem_matches(&cfg, existing)
}
(DeclaredQdiscType::Tbf { .. }, Some(QdiscOptions::Tbf(live))) => {
let cfg = declared.tbf_config().expect("matched the Tbf arm");
let declared_peak = cfg
.peakrate
.map_or(0, |r| r.as_bytes_per_sec());
live.rate == cfg.rate.as_bytes_per_sec()
&& live.burst == cfg.burst.as_u32_saturating()
&& live.limit == cfg.limit.as_u32_saturating()
&& live.peakrate == declared_peak
&& live.mtu == cfg.mtu
}
(DeclaredQdiscType::Htb { .. }, Some(QdiscOptions::Htb(live))) => {
let cfg = declared.htb_config().expect("matched the Htb arm");
live.default_class == cfg.default_class && live.rate2quantum == cfg.r2q
}
(
DeclaredQdiscType::FqCodel {
limit,
target_us,
interval_us,
quantum,
ecn,
..
},
Some(QdiscOptions::FqCodel(live)),
) => {
limit.is_none_or(|l| live.limit == l)
&& target_us.is_none_or(|t| codel_round_trip_us(t) == live.target_us)
&& interval_us.is_none_or(|i| codel_round_trip_us(i) == live.interval_us)
&& quantum.is_none_or(|q| live.quantum == q)
&& ecn.is_none_or(|e| live.ecn == e)
}
(
DeclaredQdiscType::Sfq {
perturb_secs,
limit,
quantum,
..
},
Some(QdiscOptions::Sfq(live)),
) => {
perturb_secs.is_none_or(|p| live.perturb_period == i32::try_from(p).unwrap_or(i32::MAX))
&& limit.is_none_or(|l| live.limit == l)
&& quantum.is_none_or(|q| live.quantum == q)
}
(DeclaredQdiscType::Prio { .. }, Some(QdiscOptions::Prio(live))) => {
let cfg = declared.prio_config().expect("matched the Prio arm");
live.bands == cfg.bands && live.priomap == cfg.priomap
}
(DeclaredQdiscType::Ingress | DeclaredQdiscType::Clsact, _) => true,
(_, _) => {
declared_options_bytes(declared).as_slice() == existing.raw_options().unwrap_or(&[])
}
}
}
fn declared_options_bytes(t: &DeclaredQdiscType) -> Vec<u8> {
let mut builder = MessageBuilder::new(0, 0);
let start = builder.len();
let write_result: Result<()> = match t {
DeclaredQdiscType::Netem { .. } => t
.netem_config()
.expect("matched the Netem arm")
.write_options(&mut builder),
DeclaredQdiscType::Htb { .. } => t
.htb_config()
.expect("matched the Htb arm")
.write_options(&mut builder),
DeclaredQdiscType::FqCodel { .. } => t
.fq_codel_config(true)
.expect("matched the FqCodel arm")
.write_options(&mut builder),
DeclaredQdiscType::Tbf { .. } => t
.tbf_config()
.expect("matched the Tbf arm")
.write_options(&mut builder),
DeclaredQdiscType::Sfq { .. } => t
.sfq_config()
.expect("matched the Sfq arm")
.write_options(&mut builder),
DeclaredQdiscType::Prio { .. } => t
.prio_config()
.expect("matched the Prio arm")
.write_options(&mut builder),
DeclaredQdiscType::Ingress => IngressConfig::new().write_options(&mut builder),
DeclaredQdiscType::Clsact => ClsactConfig::new().write_options(&mut builder),
};
let end = builder.len();
if write_result.is_err() {
return Vec::new();
}
builder.as_bytes()[start..end].to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::netlink::config::types::MacvlanMode;
fn declared(name: &str, link_type: DeclaredLinkType) -> DeclaredLink {
DeclaredLink {
name: name.to_string(),
link_type,
state: LinkState::Unchanged,
mtu: None,
master: None,
address: None,
}
}
#[test]
fn topo_sort_no_op_when_empty_or_singleton() {
let mut links: Vec<DeclaredLink> = vec![];
topo_sort_links_to_add(&mut links);
assert!(links.is_empty());
let mut links = vec![declared("eth0", DeclaredLinkType::Dummy)];
topo_sort_links_to_add(&mut links);
assert_eq!(links.len(), 1);
assert_eq!(links[0].name, "eth0");
}
#[test]
fn topo_sort_independent_links_preserve_declared_order() {
let mut links = vec![
declared("eth1", DeclaredLinkType::Dummy),
declared("eth0", DeclaredLinkType::Dummy),
declared("br0", DeclaredLinkType::Bridge),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth1", "eth0", "br0"],
"independent links must keep declared order (stable sort)"
);
}
#[test]
fn topo_sort_promotes_parent_before_child_vlan() {
let mut links = vec![
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "eth0.42"],
"parent must precede child after topo-sort"
);
}
#[test]
fn topo_sort_keeps_correct_order_when_already_sorted() {
let mut links = vec![
declared("eth0", DeclaredLinkType::Dummy),
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "eth0.42"]
);
}
#[test]
fn topo_sort_handles_parent_not_in_batch() {
let mut links = vec![
declared("br0", DeclaredLinkType::Bridge),
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(), vlan_id: 42, protocol: None,
},
),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["br0", "eth0.42"],
"out-of-batch parent does NOT trigger reorder"
);
}
#[test]
fn topo_sort_handles_macvlan_parent_dep() {
let mut links = vec![
declared(
"macv0",
DeclaredLinkType::Macvlan {
parent: "eth0".into(),
mode: MacvlanMode::default(),
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "macv0"]
);
}
#[test]
fn topo_sort_chain_three_levels() {
let mut links = vec![
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
declared("br0", DeclaredLinkType::Bridge), declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["br0", "eth0", "eth0.42"]
);
}
#[test]
fn declared_options_bytes_differs_when_param_changes() {
let a = DeclaredQdiscType::Htb { default_class: 0x10 };
let b = DeclaredQdiscType::Htb { default_class: 0x20 };
assert_ne!(declared_options_bytes(&a), declared_options_bytes(&b));
}
#[test]
fn declared_options_bytes_stable_for_same_input() {
let cfg = DeclaredQdiscType::Netem {
delay_us: Some(100_000),
jitter_us: Some(10_000),
loss_percent: Some(0.5),
limit: Some(1000),
duplicate_percent: None,
corrupt_percent: None,
reorder_percent: None,
loss_correlation: None,
delay_correlation: None,
rate_bps: None,
duplicate_correlation: None,
corrupt_correlation: None,
reorder_correlation: None,
gap: None,
};
assert_eq!(declared_options_bytes(&cfg), declared_options_bytes(&cfg));
}
#[test]
fn declared_options_bytes_differs_across_netem_params() {
let a = DeclaredQdiscType::Netem {
delay_us: Some(100_000),
jitter_us: None,
loss_percent: None,
limit: None,
duplicate_percent: None,
corrupt_percent: None,
reorder_percent: None,
loss_correlation: None,
delay_correlation: None,
rate_bps: None,
duplicate_correlation: None,
corrupt_correlation: None,
reorder_correlation: None,
gap: None,
};
let b = DeclaredQdiscType::Netem {
delay_us: Some(200_000),
jitter_us: None,
loss_percent: None,
limit: None,
duplicate_percent: None,
corrupt_percent: None,
reorder_percent: None,
loss_correlation: None,
delay_correlation: None,
rate_bps: None,
duplicate_correlation: None,
corrupt_correlation: None,
reorder_correlation: None,
gap: None,
};
assert_ne!(declared_options_bytes(&a), declared_options_bytes(&b));
}
fn live(kind: &str, options: Option<Vec<u8>>) -> TcMessage {
TcMessage {
kind: Some(kind.to_string()),
options,
..TcMessage::default()
}
}
fn attr_u32(attr_type: u16, value: u32) -> Vec<u8> {
let mut out = Vec::with_capacity(8);
out.extend_from_slice(&8u16.to_ne_bytes());
out.extend_from_slice(&attr_type.to_ne_bytes());
out.extend_from_slice(&value.to_ne_bytes());
out
}
fn netem_declared(delay_us: u32) -> DeclaredQdiscType {
DeclaredQdiscType::Netem {
delay_us: Some(delay_us),
jitter_us: Some(10_000),
loss_percent: Some(0.5),
limit: Some(1000),
duplicate_percent: None,
corrupt_percent: None,
reorder_percent: Some(3.0),
loss_correlation: None,
delay_correlation: None,
rate_bps: Some(12_500_000),
duplicate_correlation: None,
corrupt_correlation: None,
reorder_correlation: Some(50.0),
gap: Some(5),
}
}
#[test]
fn qdisc_params_match_treats_empty_existing_as_mismatch_when_declared_nonempty() {
let cfg = DeclaredQdiscType::Htb { default_class: 0x10 };
assert!(!qdisc_params_match(&cfg, &live("htb", None)));
assert!(!qdisc_params_match(&cfg, &live("htb", Some(Vec::new()))));
}
#[test]
fn qdisc_params_match_clsact_has_no_options() {
let cfg = DeclaredQdiscType::Clsact;
assert!(qdisc_params_match(&cfg, &live("clsact", Some(Vec::new()))));
assert!(qdisc_params_match(&cfg, &live("clsact", None)));
}
#[test]
fn netem_matches_the_kernels_echo_with_extra_attributes() {
use crate::netlink::types::tc::qdisc::netem::TCA_NETEM_ECN;
let declared = netem_declared(100_000);
let mut echo = declared_options_bytes(&declared);
echo.extend(attr_u32(TCA_NETEM_ECN, 0));
assert_ne!(declared_options_bytes(&declared), echo, "the byte compare would say differs");
assert!(qdisc_params_match(&declared, &live("netem", Some(echo.clone()))));
assert!(!qdisc_params_match(&netem_declared(200_000), &live("netem", Some(echo))));
}
#[test]
fn fq_codel_compares_in_codel_ticks_and_only_declared_fields() {
use crate::netlink::types::tc::qdisc::fq_codel::TCA_FQ_CODEL_TARGET;
let fq = |target_us, flows, quantum, ecn| DeclaredQdiscType::FqCodel {
limit: None,
target_us,
interval_us: None,
flows,
quantum,
ecn,
};
let declared = fq(Some(20_000), None, None, None);
let echo = live("fq_codel", Some(attr_u32(TCA_FQ_CODEL_TARGET, 19_999)));
assert!(qdisc_params_match(&declared, &echo));
let other = fq(Some(18_000), None, None, None);
assert!(!qdisc_params_match(&other, &echo));
let bare = fq(None, None, None, None);
assert!(qdisc_params_match(&bare, &echo));
}
#[test]
fn fq_codel_compares_the_0_28_knobs() {
use crate::netlink::types::tc::qdisc::fq_codel::{
TCA_FQ_CODEL_ECN, TCA_FQ_CODEL_FLOWS, TCA_FQ_CODEL_QUANTUM,
};
let fq = |flows, quantum, ecn| DeclaredQdiscType::FqCodel {
limit: None,
target_us: None,
interval_us: None,
flows,
quantum,
ecn,
};
let mut opts = attr_u32(TCA_FQ_CODEL_FLOWS, 1024);
opts.extend(attr_u32(TCA_FQ_CODEL_QUANTUM, 300));
opts.extend(attr_u32(TCA_FQ_CODEL_ECN, 1));
let echo = live("fq_codel", Some(opts));
assert!(qdisc_params_match(&fq(Some(1024), Some(300), Some(true)), &echo));
assert!(
qdisc_params_match(&fq(None, None, None), &echo),
"undeclared knobs must not make the diff fire"
);
assert!(
qdisc_params_match(&fq(Some(512), None, None), &echo),
"a flows difference is not actionable, so it must not make the diff fire"
);
assert!(!qdisc_params_match(&fq(None, Some(1514), None), &echo));
assert!(!qdisc_params_match(&fq(None, None, Some(false)), &echo));
}
#[test]
fn htb_default_class_is_compared_as_a_field() {
use crate::netlink::types::tc::qdisc::htb::TCA_HTB_DIRECT_QLEN;
let declared = DeclaredQdiscType::Htb { default_class: 0x10 };
let mut echo = declared_options_bytes(&declared);
echo.extend(attr_u32(TCA_HTB_DIRECT_QLEN, 1000));
assert!(qdisc_params_match(&declared, &live("htb", Some(echo.clone()))));
let other = DeclaredQdiscType::Htb { default_class: 0x20 };
assert!(!qdisc_params_match(&other, &live("htb", Some(echo))));
}
#[test]
fn tbf_rate_burst_and_limit_are_compared_as_fields() {
let tbf = |rate_bps, burst_bytes, peakrate_bps, mtu| DeclaredQdiscType::Tbf {
rate_bps,
burst_bytes,
limit_bytes: Some(65_536),
peakrate_bps,
mtu,
};
let declared = tbf(125_000, 32_768, None, None);
let echo = live("tbf", Some(declared_options_bytes(&declared)));
assert!(qdisc_params_match(&declared, &echo));
let faster = tbf(250_000, 32_768, None, None);
assert!(!qdisc_params_match(&faster, &echo));
let bigger_bucket = tbf(125_000, 65_536, None, None);
assert!(!qdisc_params_match(&bigger_bucket, &echo));
let peaked = tbf(125_000, 32_768, Some(250_000), Some(1600));
let peaked_echo = live("tbf", Some(declared_options_bytes(&peaked)));
assert!(qdisc_params_match(&peaked, &peaked_echo));
assert!(
!qdisc_params_match(&tbf(125_000, 32_768, Some(500_000), Some(1600)), &peaked_echo),
"a changed peakrate must be seen"
);
assert!(
!qdisc_params_match(&tbf(125_000, 32_768, Some(250_000), Some(9000)), &peaked_echo),
"a changed mtu must be seen"
);
assert!(
!qdisc_params_match(&declared, &peaked_echo),
"dropping the peakrate must be seen"
);
}
#[test]
fn sfq_and_prio_compare_their_declared_fields() {
let mk_sfq = |perturb_secs, limit, quantum| DeclaredQdiscType::Sfq {
perturb_secs,
limit,
quantum,
};
let sfq = mk_sfq(Some(10), None, None);
let echo = live("sfq", Some(declared_options_bytes(&sfq)));
assert!(qdisc_params_match(&sfq, &echo));
let other = mk_sfq(Some(20), None, None);
assert!(!qdisc_params_match(&other, &echo));
let tuned = mk_sfq(Some(10), Some(200), Some(1514));
let tuned_echo = live("sfq", Some(declared_options_bytes(&tuned)));
assert!(qdisc_params_match(&tuned, &tuned_echo));
assert!(qdisc_params_match(&mk_sfq(None, None, None), &tuned_echo));
assert!(!qdisc_params_match(&mk_sfq(None, Some(64), None), &tuned_echo));
assert!(!qdisc_params_match(&mk_sfq(None, None, Some(300)), &tuned_echo));
let prio = DeclaredQdiscType::Prio { bands: Some(3) };
let echo = live("prio", Some(declared_options_bytes(&prio)));
assert!(qdisc_params_match(&prio, &echo));
let other = DeclaredQdiscType::Prio { bands: Some(4) };
assert!(!qdisc_params_match(&other, &echo));
}
#[test]
fn apply_options_builders_compose() {
use super::super::apply::ApplyOptions;
let opts = ApplyOptions::default()
.with_dry_run(true)
.with_continue_on_error(true);
assert!(opts.dry_run);
assert!(opts.continue_on_error);
}
#[test]
fn apply_options_default_is_safe() {
use super::super::apply::ApplyOptions;
let opts = ApplyOptions::default();
assert!(!opts.dry_run);
assert!(!opts.continue_on_error);
}
#[test]
fn link_changes_display_matches_summary() {
let c = LinkChanges {
set_mtu: Some(9000),
set_up: true,
..LinkChanges::default()
};
assert_eq!(c.to_string(), c.summary());
assert!(c.to_string().contains("mtu=9000"));
assert!(c.to_string().contains("up"));
}
#[test]
fn link_changes_display_empty_when_no_changes() {
let c = LinkChanges::default();
assert_eq!(c.to_string(), "");
}
#[test]
fn default_v4_route_is_zero_zero() {
use super::super::types::RouteBuilder;
let r = RouteBuilder::default_v4();
let with_gw = r.via("192.0.2.1");
drop(with_gw);
}
#[test]
fn default_v6_route_is_unspecified_slash_zero() {
use super::super::types::RouteBuilder;
let r = RouteBuilder::default_v6();
let with_gw = r.via("2001:db8::1");
drop(with_gw);
}
#[test]
fn topo_sort_promotes_vxlan_underlay_before_vxlan() {
let mut links = vec![
declared(
"vxlan42",
DeclaredLinkType::Vxlan {
vni: 42,
remote: None,
local: None,
port: None,
underlay_dev: Some("eth0".into()),
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
let positions: HashMap<String, usize> = links
.iter()
.enumerate()
.map(|(i, l)| (l.name.clone(), i))
.collect();
assert!(
positions["eth0"] < positions["vxlan42"],
"underlay must be created before VXLAN"
);
}
#[test]
fn topo_sort_promotes_master_before_slave() {
let mut dummy = declared("dummy0", DeclaredLinkType::Dummy);
dummy.master = Some("br0".into());
let mut links = vec![dummy, declared("br0", DeclaredLinkType::Bridge)];
topo_sort_links_to_add(&mut links);
let positions: HashMap<String, usize> = links
.iter()
.enumerate()
.map(|(i, l)| (l.name.clone(), i))
.collect();
assert!(
positions["br0"] < positions["dummy0"],
"master must be created before slave"
);
}
#[test]
fn display_matches_summary() {
let diff = ConfigDiff::default();
#[allow(deprecated)]
{
assert_eq!(format!("{diff}"), diff.summary());
let mut d = ConfigDiff::default();
d.links_to_modify
.push(("eth0".to_string(), LinkChanges::default()));
assert_eq!(format!("{d}"), d.summary());
}
}
fn declared_addr(dev: &str, addr: &str, plen: u8) -> DeclaredAddress {
DeclaredAddress {
dev: dev.to_string(),
address: addr.parse().unwrap(),
prefix_len: plen,
}
}
fn declared_route(dst: &str, plen: u8) -> DeclaredRoute {
DeclaredRoute {
destination: dst.parse().unwrap(),
prefix_len: plen,
gateway: None,
dev: None,
metric: None,
table: Some(254),
route_type: DeclaredRouteType::Unicast,
}
}
#[test]
fn diff_options_purge_builder_toggles_flag() {
assert!(!DiffOptions::default().purge);
assert!(DiffOptions::default().purge(true).purge);
assert!(!DiffOptions::default().purge(true).purge(false).purge);
}
fn kernel_static_route(dst: &str, plen: u8, table: u32) -> RouteMessage {
crate::netlink::messages::RouteMessageBuilder::new()
.destination(dst.parse().unwrap(), plen)
.table(table)
.protocol(RouteProtocol::Static)
.route_type(RouteType::Unicast)
.build()
}
fn purge_routes(config: &NetworkConfig, current: &[RouteMessage], opts: DiffOptions) -> Vec<(String, u32)> {
let mut diff = ConfigDiff::default();
diff_routes(config, current, &HashMap::new(), &opts, &mut diff);
diff.routes_to_remove
.iter()
.map(|r| (format!("{}/{}", r.destination, r.prefix_len), r.table.unwrap()))
.collect()
}
#[test]
fn purge_scope_is_main_plus_declared_plus_listed_tables() {
let config = NetworkConfig::new()
.route("10.1.0.0/24", |r| r.table(100))
.unwrap();
let opts = DiffOptions::default().purge(true);
assert_eq!(
opts.purge_table_scope(&config),
HashSet::from([254, 100]),
"main plus the table a declared route names"
);
let opts = DiffOptions::default().purge(true).purge_tables([200, 201]);
assert_eq!(opts.purge_table_scope(&config), HashSet::from([254, 100, 200, 201]));
assert_eq!(
DiffOptions::default().purge_table_scope(&NetworkConfig::new()),
HashSet::from([254]),
"a config with no routes owns main and nothing else"
);
}
#[test]
fn purge_removes_an_undeclared_route_from_a_declared_table() {
let config = NetworkConfig::new()
.route("10.1.0.0/24", |r| r.table(100))
.unwrap();
let current = [
kernel_static_route("10.1.0.0", 24, 100),
kernel_static_route("10.2.0.0", 24, 100),
];
let removed = purge_routes(&config, ¤t, DiffOptions::default().purge(true));
assert_eq!(removed, vec![("10.2.0.0/24".to_string(), 100)]);
}
#[test]
fn purge_leaves_tables_the_config_never_mentions_alone() {
let config = NetworkConfig::new().route("10.1.0.0/24", |r| r).unwrap();
let current = [
kernel_static_route("10.1.0.0", 24, 254),
kernel_static_route("10.9.0.0", 24, 254),
kernel_static_route("10.2.0.0", 24, 100),
];
let removed = purge_routes(&config, ¤t, DiffOptions::default().purge(true));
assert_eq!(
removed,
vec![("10.9.0.0/24".to_string(), 254)],
"only the undeclared main-table route; table 100 is out of scope"
);
}
#[test]
fn purge_tables_reaches_a_table_with_no_declared_route_left() {
let config = NetworkConfig::new();
let current = [kernel_static_route("10.2.0.0", 24, 100)];
assert!(
purge_routes(&config, ¤t, DiffOptions::default().purge(true)).is_empty(),
"without purge_tables the now-unmentioned table is left alone"
);
let removed = purge_routes(
&config,
¤t,
DiffOptions::default().purge(true).purge_tables([100]),
);
assert_eq!(removed, vec![("10.2.0.0/24".to_string(), 100)]);
}
#[test]
fn purge_never_touches_local_or_kernel_protocol_routes_in_scope() {
let config = NetworkConfig::new()
.route("10.1.0.0/24", |r| r.table(100))
.unwrap();
let kernel_proto = crate::netlink::messages::RouteMessageBuilder::new()
.destination("10.3.0.0".parse().unwrap(), 24)
.table(100)
.protocol(RouteProtocol::Kernel)
.route_type(RouteType::Unicast)
.build();
let local = crate::netlink::messages::RouteMessageBuilder::new()
.destination("10.1.0.1".parse().unwrap(), 32)
.table(255)
.protocol(RouteProtocol::Kernel)
.route_type(RouteType::Local)
.build();
let removed = purge_routes(&config, &[kernel_proto, local], DiffOptions::default().purge(true));
assert!(removed.is_empty(), "got {removed:?}");
}
#[test]
fn removal_collections_count_toward_is_empty_and_change_count() {
let mut d = ConfigDiff::default();
assert!(d.is_empty());
assert_eq!(d.change_count(), 0);
d.addresses_to_remove
.push(declared_addr("eth0", "10.0.0.5", 24));
assert!(!d.is_empty());
assert_eq!(d.change_count(), 1);
d.routes_to_remove.push(declared_route("10.1.0.0", 24));
assert!(!d.is_empty());
assert_eq!(d.change_count(), 2);
}
#[test]
fn display_renders_removal_lines() {
let mut d = ConfigDiff::default();
d.addresses_to_remove
.push(declared_addr("eth0", "10.0.0.5", 24));
d.routes_to_remove.push(declared_route("10.1.0.0", 24));
let rendered = format!("{d}");
assert!(
rendered.contains("- address 10.0.0.5/24 on eth0"),
"address removal line missing: {rendered}"
);
assert!(
rendered.contains("- route 10.1.0.0/24"),
"route removal line missing: {rendered}"
);
}
}