diff --git a/Cargo.lock b/Cargo.lock index 5d72be0c..10dc9490 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ dependencies = [ "slog", "slog-async", "slog-term", - "softnpu 0.2.0 (git+https://github.com/oxidecomputer/softnpu?branch=main)", + "softnpu 0.2.0 (git+https://github.com/oxidecomputer/softnpu?branch=zl%2Fmulticast)", "strum 0.28.0", "thiserror 2.0.18", "tofino 0.1.0 (git+https://github.com/oxidecomputer/tofino?branch=main)", @@ -5002,6 +5002,17 @@ dependencies = [ "usdt 0.5.0", ] +[[package]] +name = "p4rs" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/p4?branch=zl%2Fmulticast#35bca4f7d8ff5ccdb0fe443dc718699f0456153e" +dependencies = [ + "bitvec", + "num", + "serde", + "usdt 0.5.0", +] + [[package]] name = "p9ds" version = "0.1.0" @@ -7167,9 +7178,9 @@ dependencies = [ [[package]] name = "softnpu" version = "0.2.0" -source = "git+https://github.com/oxidecomputer/softnpu?branch=main#7e015d167a914777bc21434d1c61f205f22993b1" +source = "git+https://github.com/oxidecomputer/softnpu?branch=zl%2Fmulticast#26615d82bdad8dd9928239b88896864f83aab326" dependencies = [ - "p4rs", + "p4rs 0.1.0 (git+https://github.com/oxidecomputer/p4?branch=zl%2Fmulticast)", "serde", "serde_json", "tokio", @@ -7180,7 +7191,7 @@ name = "softnpu" version = "0.2.0" source = "git+https://github.com/oxidecomputer/softnpu#7e015d167a914777bc21434d1c61f205f22993b1" dependencies = [ - "p4rs", + "p4rs 0.1.0 (git+https://github.com/oxidecomputer/p4?branch=main)", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 49d0b634..942cddb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,8 @@ oxnet = { version = "0.1.6", default-features = false, features = ["schemars", " propolis = { git = "https://github.com/oxidecomputer/propolis" } smf = { git = "https://github.com/illumos/smf-rs" } slog-error-chain = { git = "https://github.com/oxidecomputer/slog-error-chain", branch = "main" } -softnpu-lib = { git = "https://github.com/oxidecomputer/softnpu" , package = "softnpu" , branch = "main"} +# TODO: set back to main once zl/multicast merges +softnpu-lib = { git = "https://github.com/oxidecomputer/softnpu" , package = "softnpu" , branch = "zl/multicast"} tofino = { git = "https://github.com/oxidecomputer/tofino", branch = "main" } transceiver-controller = { git = "https://github.com/oxidecomputer/transceiver-control", branch = "main" } diff --git a/asic/src/softnpu/mcast.rs b/asic/src/softnpu/mcast.rs new file mode 100644 index 00000000..b015d88b --- /dev/null +++ b/asic/src/softnpu/mcast.rs @@ -0,0 +1,477 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! [Sidecar-lite] handles packet replication via port bitmaps in the P4 +//! pipeline. This module tracks group membership for the `AsicMulticastOps` +//! contract and caches the installed replication entries for replaying +//! membership updates into sidecar-lite. +//! +//! [Sidecar-lite]: https://github.com/oxidecomputer/sidecar-lite + +use std::collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}; + +use aal::{AsicError, AsicResult}; + +/// The sidecar-lite replication table that holds underlay/external +/// port bitmaps for an underlay multicast group. +pub const MCAST_REPLICATION_V6_TABLE: &str = + "ingress.mcast.mcast_replication_v6"; + +/// The sidecar-lite action that writes the external and underlay port bitmaps +/// (and replication id) for a [`MCAST_REPLICATION_V6_TABLE`] entry. +pub const SET_PORT_BITMAP_ACTION: &str = "set_port_bitmap"; + +/// A replication table entry's group references, tracked for later +/// membership changes to recompute port bitmaps when necessary. +/// +/// On the production sidecar, the replication table holds a group identifier, +/// while the replication engine tracks live membership subscriptions, which means +/// that any port additions do not trigger table rewrites. +/// +/// The SoftNPU pipeline, however, is built around a generic port bitmap +/// structure, appended into the table entry at write time, forcing each +/// membership change to rewrite each entry that references the mutated +/// group. +#[derive(Clone, Copy, Debug)] +struct ReplEntryRef { + external_grp: u16, + underlay_grp: u16, + rid: u16, +} + +/// A replication entry refreshed against current membership, available to be +/// rewritten into the SoftNPU pipeline. +#[derive(Clone, Debug)] +pub struct RefreshedReplEntry { + /// Match keyset bytes identifying the table entry (the underlay group's + /// destination address). + pub keyset: Vec, + /// Ports in the external replication group, as a port bitmap. + pub external_bitmap: u128, + /// Ports in the underlay replication group, as a port bitmap. + pub underlay_bitmap: u128, + /// Replication ID, which keys the egress decap-ports table. + pub rid: u16, +} + +/// Multicast group membership and the replication entries derived from it. +/// +/// Unlike Tofino, SoftNPU has no PRE, leading us to track group membership +/// here and rewrite the mutated replication entries' port bitmap when a +/// group's membership change is triggered. +#[derive(Default)] +pub struct McGroupData { + groups: HashMap>, + /// Replication table entries keyed on their matched keyset, recorded at + /// table-write time so membership changes can refresh bitmaps. + repl_entries: BTreeMap, ReplEntryRef>, +} + +fn no_group(group_id: u16) -> AsicError { + AsicError::Missing(format!("no such multicast group: {group_id}")) +} + +impl McGroupData { + /// Get the list of multicast domains. + pub fn domains(&self) -> Vec { + self.groups.keys().copied().collect() + } + + /// Build a 128-bit port bitmap for a group. Bit N is set if port N + /// is a member. + /// + /// Returns zero for unknown groups. + pub fn port_bitmap(&self, group_id: u16) -> u128 { + self.groups.get(&group_id).map_or(0, |ports| { + ports.iter().fold(0, |bitmap, &port| bitmap | (1u128 << port)) + }) + } + + /// Get the number of ports in a multicast domain. + pub fn domain_port_count(&self, group_id: u16) -> AsicResult { + match self.groups.get(&group_id) { + Some(g) => Ok(g.len()), + None => Err(no_group(group_id)), + } + } + + /// Add a port to a multicast domain. Port must be < 128 to fit + /// within sidecar-lite's 128-bit replication bitmap. + pub fn domain_port_add( + &mut self, + group_id: u16, + port: u16, + _rid: u16, + _level1_excl_id: u16, + ) -> AsicResult<()> { + if port >= 128 { + return Err(AsicError::InvalidArg(format!( + "port {port} exceeds softnpu 128-port bitmap limit" + ))); + } + + let group = + self.groups.get_mut(&group_id).ok_or_else(|| no_group(group_id))?; + + group.insert(port).then_some(()).ok_or_else(|| { + AsicError::Exists(format!( + "multicast group {group_id} already contains port {port}" + )) + }) + } + + /// Remove a port from a multicast domain. + pub fn domain_port_remove( + &mut self, + group_id: u16, + port: u16, + ) -> AsicResult<()> { + let group = + self.groups.get_mut(&group_id).ok_or_else(|| no_group(group_id))?; + + group.remove(&port).then_some(()).ok_or_else(|| { + AsicError::Missing(format!( + "multicast group {group_id} doesn't contain port {port}" + )) + }) + } + + /// Create a multicast domain. + pub fn domain_create(&mut self, group_id: u16) -> AsicResult<()> { + match self.groups.entry(group_id) { + Entry::Occupied(_) => Err(AsicError::Exists(format!( + "multicast group {group_id} already exists" + ))), + Entry::Vacant(vacant) => { + vacant.insert(HashSet::new()); + Ok(()) + } + } + } + + /// Destroy a multicast domain. + pub fn domain_destroy(&mut self, group_id: u16) -> AsicResult<()> { + self.groups.remove(&group_id).ok_or_else(|| no_group(group_id))?; + self.repl_entries.retain(|_, entry| { + entry.external_grp != group_id && entry.underlay_grp != group_id + }); + Ok(()) + } + + /// Get the total number of multicast domains. + pub fn domains_count(&self) -> usize { + self.groups.len() + } + + /// Record a replication table entry's group references at (table-)write + /// time so that a later membership mutation can refresh its related + /// bitmaps. + pub fn record_repl_entry( + &mut self, + keyset: Vec, + external_grp: u16, + underlay_grp: u16, + rid: u16, + ) { + self.repl_entries + .insert(keyset, ReplEntryRef { external_grp, underlay_grp, rid }); + } + + /// Forget a replication table entry when it is removed from the pipeline. + pub fn forget_repl_entry(&mut self, keyset: &[u8]) { + self.repl_entries.remove(keyset); + } + + /// Forget every tracked replication entry when the replication table is + /// cleared, so a later membership change for a reused group identifier + /// cannot resurrect a stale bitmap from a forgotten keyset. + pub fn clear_repl_entries(&mut self) { + self.repl_entries.clear(); + } + + /// Recompute the bitmaps for every replication entry that references + /// `group_id` in either its external or underlay slot, returning the + /// refreshed [`RefreshedReplEntry`] values for the caller to rewrite into + /// the SoftNPU pipeline. + pub fn refresh_params_for_group( + &self, + group_id: u16, + ) -> Vec { + self.repl_entries + .iter() + .filter(|(_, e)| { + e.external_grp == group_id || e.underlay_grp == group_id + }) + .map(|(keyset, e)| RefreshedReplEntry { + keyset: keyset.clone(), + external_bitmap: self.port_bitmap(e.external_grp), + underlay_bitmap: self.port_bitmap(e.underlay_grp), + rid: e.rid, + }) + .collect() + } + + /// Validate that the current group count does not exceed the limit. + pub fn set_max_nodes( + &mut self, + max_nodes: u32, + _max_link_aggregated_nodes: u32, + ) -> AsicResult<()> { + let total = self.domains_count(); + if u64::try_from(total).unwrap_or(u64::MAX) > u64::from(max_nodes) { + return Err(AsicError::InvalidArg(format!( + "number of multicast groups {total} exceeds max nodes {max_nodes}" + ))); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_lifecycle() { + let mut group_data = McGroupData::default(); + + // Create group, add ports. + group_data.domain_create(100).unwrap(); + group_data.domain_port_add(100, 1, 0, 0).unwrap(); + group_data.domain_port_add(100, 5, 0, 0).unwrap(); + + assert_eq!(group_data.domain_port_count(100).unwrap(), 2); + assert_eq!(group_data.domains_count(), 1); + + // Remove a port. + group_data.domain_port_remove(100, 1).unwrap(); + assert_eq!(group_data.domain_port_count(100).unwrap(), 1); + + // Destroy group. + group_data.domain_destroy(100).unwrap(); + assert_eq!(group_data.domains_count(), 0); + } + + #[test] + fn duplicate_group_rejected() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + assert!(matches!( + group_data.domain_create(1), + Err(AsicError::Exists(_)) + )); + } + + #[test] + fn duplicate_port_rejected() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + group_data.domain_port_add(1, 5, 0, 0).unwrap(); + assert!(matches!( + group_data.domain_port_add(1, 5, 0, 0), + Err(AsicError::Exists(_)) + )); + } + + #[test] + fn remove_nonexistent_port_rejected() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + assert!(matches!( + group_data.domain_port_remove(1, 99), + Err(AsicError::Missing(_)) + )); + } + + #[test] + fn operations_on_missing_group_rejected() { + let mut group_data = McGroupData::default(); + assert!(matches!( + group_data.domain_port_add(42, 1, 0, 0), + Err(AsicError::Missing(_)) + )); + assert!(matches!( + group_data.domain_port_remove(42, 1), + Err(AsicError::Missing(_)) + )); + assert!(matches!( + group_data.domain_port_count(42), + Err(AsicError::Missing(_)) + )); + assert!(matches!( + group_data.domain_destroy(42), + Err(AsicError::Missing(_)) + )); + } + + #[test] + fn port_bitmap_zero_for_empty_or_unknown_group() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + assert_eq!(group_data.port_bitmap(1), 0); + assert_eq!(group_data.port_bitmap(999), 0); + } + + #[test] + fn port_bitmap_tracks_membership() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + group_data.domain_port_add(1, 0, 0, 0).unwrap(); + group_data.domain_port_add(1, 3, 0, 0).unwrap(); + group_data.domain_port_add(1, 7, 0, 0).unwrap(); + assert_eq!(group_data.port_bitmap(1), (1 << 0) | (1 << 3) | (1 << 7)); + + group_data.domain_port_remove(1, 0).unwrap(); + assert_eq!(group_data.port_bitmap(1), (1 << 3) | (1 << 7)); + } + + #[test] + fn port_bitmap_high_port() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + group_data.domain_port_add(1, 127, 0, 0).unwrap(); + + let bm = group_data.port_bitmap(1); + assert_eq!(bm & (1u128 << 127), 1u128 << 127); + } + + #[test] + fn port_add_rejects_ports_above_127() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + // Port 128 is out of range for a 128-bit bitmap. + assert!(matches!( + group_data.domain_port_add(1, 128, 0, 0), + Err(AsicError::InvalidArg(_)) + )); + } + + #[test] + fn set_max_nodes_validates() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + group_data.domain_create(2).unwrap(); + + assert!(matches!( + group_data.set_max_nodes(1, 0), + Err(AsicError::InvalidArg(_)) + )); + assert!(group_data.set_max_nodes(2, 0).is_ok()); + assert!(group_data.set_max_nodes(100, 0).is_ok()); + } + + #[test] + fn refresh_recomputes_bitmap_after_membership_change() { + let mut group_data = McGroupData::default(); + // Underlay group with one member, recorded as a replication + // entry keyed by the underlay destination address bytes. + group_data.domain_create(65533).unwrap(); + group_data.domain_port_add(65533, 1, 0, 0).unwrap(); + // ff04::1, an admin-scoped IPv6 multicast destination, matching the + // 128-bit key of the sidecar-lite replication table. + let keyset = std::net::Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1) + .octets() + .to_vec(); + group_data.record_repl_entry(keyset.clone(), 0, 65533, 65533); + + // Refreshed against current membership @ only port 1. + let initial = group_data.refresh_params_for_group(65533); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].underlay_bitmap, 1u128 << 1); + + // A later member add must surface in a fresh refresh. + group_data.domain_port_add(65533, 2, 0, 0).unwrap(); + group_data.domain_port_add(65533, 3, 0, 0).unwrap(); + let refreshed = group_data.refresh_params_for_group(65533); + assert_eq!(refreshed.len(), 1); + assert_eq!(refreshed[0].keyset, keyset); + assert_eq!(refreshed[0].rid, 65533); + assert_eq!( + refreshed[0].underlay_bitmap, + (1u128 << 1) | (1u128 << 2) | (1u128 << 3) + ); + } + + #[test] + fn refresh_skips_unrelated_groups_and_forgotten_entries() { + let mut group_data = McGroupData::default(); + group_data.domain_create(100).unwrap(); + group_data.domain_create(200).unwrap(); + group_data.domain_port_add(100, 4, 0, 0).unwrap(); + + // These are opaque single-byte keysets standing in for distinct + // replication entries (not any real address). + let key_a = vec![0xaa]; + let key_b = vec![0xbb]; + + group_data.record_repl_entry(key_a.clone(), 0, 100, 7); + group_data.record_repl_entry(key_b, 0, 200, 8); + + // Only the entry referencing group 100 is refreshed. + let refreshed = group_data.refresh_params_for_group(100); + assert_eq!(refreshed.len(), 1); + assert_eq!(refreshed[0].keyset, key_a); + + // After forgetting it, the group has no entries to refresh. + group_data.forget_repl_entry(&key_a); + assert!(group_data.refresh_params_for_group(100).is_empty()); + } + + #[test] + fn refresh_matches_external_slot() { + let mut group_data = McGroupData::default(); + group_data.domain_create(50).unwrap(); + group_data.domain_port_add(50, 9, 0, 0).unwrap(); + // Group 50 sits in the external slot here, not the underlay slot. + group_data.record_repl_entry(vec![0xcc], 50, 0, 1); + + let refreshed = group_data.refresh_params_for_group(50); + assert_eq!(refreshed.len(), 1); + assert_eq!(refreshed[0].external_bitmap, 1u128 << 9); + assert_eq!(refreshed[0].underlay_bitmap, 0); + } + + #[test] + fn clear_repl_entries_drops_all_tracked_entries() { + let mut group_data = McGroupData::default(); + group_data.domain_create(100).unwrap(); + group_data.domain_create(200).unwrap(); + group_data.domain_port_add(100, 4, 0, 0).unwrap(); + group_data.domain_port_add(200, 5, 0, 0).unwrap(); + group_data.record_repl_entry(vec![0xaa], 0, 100, 7); + group_data.record_repl_entry(vec![0xbb], 0, 200, 8); + + // Both entries are tracked before the clear. + assert_eq!(group_data.refresh_params_for_group(100).len(), 1); + assert_eq!(group_data.refresh_params_for_group(200).len(), 1); + + group_data.clear_repl_entries(); + + // After clearing, no keyset survives to be resurrected, even though the + // group memberships themselves are untouched. + assert!(group_data.refresh_params_for_group(100).is_empty()); + assert!(group_data.refresh_params_for_group(200).is_empty()); + assert_eq!(group_data.domain_port_count(100).unwrap(), 1); + assert_eq!(group_data.domain_port_count(200).unwrap(), 1); + } + + #[test] + fn destroy_group_drops_its_repl_entries() { + let mut group_data = McGroupData::default(); + group_data.domain_create(1).unwrap(); + group_data.domain_create(2).unwrap(); + group_data.domain_port_add(2, 3, 0, 0).unwrap(); + group_data.record_repl_entry(vec![0xaa], 1, 2, 7); + group_data.record_repl_entry(vec![0xbb], 2, 2, 8); + + group_data.domain_destroy(1).unwrap(); + + let refreshed = group_data.refresh_params_for_group(2); + assert_eq!(refreshed.len(), 1); + assert_eq!(refreshed[0].keyset, vec![0xbb]); + } +} diff --git a/asic/src/softnpu/mod.rs b/asic/src/softnpu/mod.rs index 5d539e9b..619edebb 100644 --- a/asic/src/softnpu/mod.rs +++ b/asic/src/softnpu/mod.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::Mutex; -use slog::{Logger, o}; +use slog::{Logger, debug, o}; use tokio::sync::mpsc; use uuid::Uuid; @@ -27,6 +27,7 @@ use common::ports::{ use softnpu_lib::ManagementRequest; +pub mod mcast; pub mod mgmt; pub mod table; @@ -102,8 +103,8 @@ pub struct Handle { log: Logger, ports: Mutex>, pub mgmt_config: mgmt::ManagementConfig, - update_tx: Mutex>>, + mc_data: Mutex, } impl Handle { @@ -129,6 +130,7 @@ impl Handle { ports: Mutex::new(HashMap::new()), mgmt_config, update_tx: Mutex::new(None), + mc_data: Mutex::new(mcast::McGroupData::default()), }) } @@ -142,50 +144,130 @@ impl Handle { } pub fn fini(&self) {} + + /// Rewrite the replication entries refreshed after a membership change. + /// + /// SoftNPU fixes each entry's port bitmaps at write time and has no + /// in-place table update. Each affected entry is removed and re-added with + /// the recomputed bitmaps. + fn rewrite_replication_entries( + &self, + entries: Vec, + ) { + use softnpu_lib::{TableAdd, TableRemove}; + + for entry in entries { + // Logged at debug, not trace, to keep the recomputed membership + // visible in a release build. The bitmaps are the post-refresh + // output ports; an empty one means that the group has no members + // in it to replicate to. + debug!( + self.log, + "refreshing mcast replication entry: \ + external_bitmap={:#x} underlay_bitmap={:#x} rid={}", + entry.external_bitmap, + entry.underlay_bitmap, + entry.rid + ); + + let remove = ManagementRequest::TableRemove(TableRemove { + keyset_data: entry.keyset.clone(), + table: mcast::MCAST_REPLICATION_V6_TABLE.to_string(), + }); + mgmt::write(remove, &self.mgmt_config); + + let parameter_data = [ + entry.external_bitmap.to_le_bytes().as_slice(), + entry.underlay_bitmap.to_le_bytes().as_slice(), + entry.rid.to_le_bytes().as_slice(), + ] + .concat(); + + let add = ManagementRequest::TableAdd(TableAdd { + table: mcast::MCAST_REPLICATION_V6_TABLE.to_string(), + action: mcast::SET_PORT_BITMAP_ACTION.to_string(), + keyset_data: entry.keyset, + parameter_data, + }); + mgmt::write(add, &self.mgmt_config); + } + } } impl AsicMulticastOps for Handle { fn mc_domains(&self) -> Vec { - let len = self.ports.lock().unwrap().len() as u16; - (0..len).collect() + let mc_data = self.mc_data.lock().unwrap(); + mc_data.domains() } - fn mc_port_count(&self, _group_id: u16) -> AsicResult { - Ok(self.ports.lock().unwrap().len()) + fn mc_port_count(&self, group_id: u16) -> AsicResult { + let mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_port_count(group_id) } fn mc_port_add( &self, - _group_id: u16, - _port: u16, - _rid: u16, - _level1_excl_id: u16, + group_id: u16, + port: u16, + rid: u16, + level1_excl_id: u16, ) -> AsicResult<()> { - Err(AsicError::OperationUnsupported) + slog::info!( + self.log, + "adding port {port} to multicast group {group_id}" + ); + // Hold the lock across the refresh and rewrite for the + // recomputation of the bitmap snapshot and its table writes are atomic + // concerning concurrent membership change(s). The rewrite touches only + // `mgmt_config`, not `mc_data`; therefore, no re-entrant lock. + let mut mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_port_add(group_id, port, rid, level1_excl_id)?; + let refreshed = mc_data.refresh_params_for_group(group_id); + self.rewrite_replication_entries(refreshed); + Ok(()) } - fn mc_port_remove(&self, _group_id: u16, _port: u16) -> AsicResult<()> { + fn mc_port_remove(&self, group_id: u16, port: u16) -> AsicResult<()> { + slog::info!( + self.log, + "removing port {port} from multicast group {group_id}" + ); + let mut mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_port_remove(group_id, port)?; + let refreshed = mc_data.refresh_params_for_group(group_id); + self.rewrite_replication_entries(refreshed); Ok(()) } - fn mc_group_create(&self, _group_id: u16) -> AsicResult<()> { - Err(AsicError::OperationUnsupported) + fn mc_group_create(&self, group_id: u16) -> AsicResult<()> { + slog::info!(self.log, "creating multicast group {group_id}"); + let mut mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_create(group_id) } - fn mc_group_destroy(&self, _group_id: u16) -> AsicResult<()> { - Ok(()) + fn mc_group_destroy(&self, group_id: u16) -> AsicResult<()> { + slog::info!(self.log, "destroying multicast group {group_id}"); + let mut mc_data = self.mc_data.lock().unwrap(); + mc_data.domain_destroy(group_id) } fn mc_groups_count(&self) -> AsicResult { - Ok(self.ports.lock().unwrap().len()) + let mc_data = self.mc_data.lock().unwrap(); + Ok(mc_data.domains_count()) } fn mc_set_max_nodes( &self, - _max_nodes: u32, - _max_link_aggregated_nodes: u32, + max_nodes: u32, + max_link_aggregated_nodes: u32, ) -> AsicResult<()> { - Ok(()) + slog::info!( + self.log, + "setting max nodes to {max_nodes}, \ + max link aggregated nodes to {max_link_aggregated_nodes}" + ); + let mut mc_data = self.mc_data.lock().unwrap(); + mc_data.set_max_nodes(max_nodes, max_link_aggregated_nodes) } } @@ -241,7 +323,7 @@ impl AsicOps for Handle { let asic_port_id = self.port_to_asic_id(port_hdl)?; let tx = self.update_tx.lock().unwrap(); if let Some(tx) = tx.as_ref() { - // When a port is enabled in softnpu, it automatically comes online. + // When a port is enabled in SoftNPU, it automatically comes online. // When switching between enabled and disabled, we send the main body // of dpd the PortUpdate events we would expect to see on real hardware. let present = @@ -267,7 +349,7 @@ impl AsicOps for Handle { } } } else { - slog::debug!(self.log, "no PortUpdate handler registered"); + debug!(self.log, "no PortUpdate handler registered"); } Ok(()) } diff --git a/asic/src/softnpu/table.rs b/asic/src/softnpu/table.rs index 56d9c1fe..9ffbcf5e 100644 --- a/asic/src/softnpu/table.rs +++ b/asic/src/softnpu/table.rs @@ -4,6 +4,8 @@ // // Copyright 2026 Oxide Computer Company +use std::hash::Hash; + use slog::{error, trace}; use softnpu_lib::{ManagementRequest, TableAdd, TableRemove}; @@ -14,16 +16,37 @@ use aal::{ }; use common::table::TableType; -/// Represents a handle to a SoftNPU ASIC table. The `id` member corresponds to -/// the table path in the P4 program. Well known sidecar-lite.p4 paths follow -/// below. +// Match field names used by the VLAN validity dispatch. +const VALID_FIELD: &str = "$valid"; +const VLAN_ID_FIELD: &str = "vlan_id"; + +// Sidecar-lite untagged table names related to VLAN dispatch. +const MCAST_NAT_V4_UNTAGGED: &str = "ingress.nat.nat_v4_mcast_untagged"; +const MCAST_NAT_V6_UNTAGGED: &str = "ingress.nat.nat_v6_mcast_untagged"; + +/// Represents a handle to a SoftNPU ASIC table. +/// +/// The `type_` member identifies the table and `softnpu_table_name()` +/// maps it to the corresponding path in sidecar-lite.p4. pub struct Table { type_: TableType, implemented: bool, + /// Alternate sidecar-lite table for untagged entries ([`VALID_FIELD`] is + /// false). + /// + /// x4c does not support `$valid` as a table key. Any table that matches + /// on VLAN validity splits into tagged/untagged variants per entry. + /// [`VLAN_ID_FIELD`] is stripped from the keyset for the untagged + /// table. + /// + /// Currently used by multicast NAT. + untagged_id: Option<&'static str>, size: usize, } impl Table { + /// Return the sidecar-lite table name for this table, or `None` if the + /// table is not implemented in the SoftNPU backend. pub fn softnpu_table_name(&self) -> Option<&'static str> { if self.implemented { match self.type_ { @@ -44,6 +67,28 @@ impl Table { TableType::ArpIpv4 => Some("ingress.resolver.resolver_v4"), TableType::NeighborIpv6 => Some("ingress.resolver.resolver_v6"), TableType::PortMacAddress => Some("ingress.mac.mac_rewrite"), + TableType::McastIpv6 => { + Some(crate::softnpu::mcast::MCAST_REPLICATION_V6_TABLE) + } + TableType::McastIpv4SrcFilter => { + Some("ingress.mcast.mcast_source_filter_v4") + } + TableType::McastIpv6SrcFilter => { + Some("ingress.mcast.mcast_source_filter_v6") + } + TableType::NatIngressIpv4Mcast => { + Some("ingress.nat.nat_v4_mcast") + } + TableType::NatIngressIpv6Mcast => { + Some("ingress.nat.nat_v6_mcast") + } + TableType::McastEgressDecapPorts => { + Some("egress.tbl_decap_ports") + } + TableType::PortMacAddressMcast => Some("egress.mcast_src_mac"), + TableType::McastEgressPortMapping => { + Some("egress.asic_id_to_port") + } _ => panic!( "implemented table {} has no softnpu table", self.type_ @@ -53,16 +98,161 @@ impl Table { None } } + + /// Inspect the VLAN `$valid` match field to route entries to the + /// tagged or untagged sidecar-lite table. + /// + /// Returns the primary table name for tables without a VLAN split, or + /// `None` when the table is unimplemented. + fn resolve_vlan_table_id( + &self, + fields: &[MatchEntryField], + ) -> Option<&str> { + if let Some(untagged) = self.untagged_id { + let is_untagged = fields.iter().any(|f| { + f.name == VALID_FIELD + && matches!( + &f.value, + MatchEntryValue::Value(ValueTypes::U64(0)) + ) + }); + if is_untagged { + return Some(untagged); + } + } + self.softnpu_table_name() + } + + /// Filter match fields for sidecar-lite serialization. + /// + /// This strips the `$valid` field and strips the `vlan_id` when targeting + /// the untagged table. + fn filter_vlan_match_fields( + &self, + fields: Vec, + target_table: &str, + ) -> Vec { + if self.untagged_id.is_none() { + return fields; + } + + let is_untagged = self.untagged_id == Some(target_table); + fields + .into_iter() + .filter(|f| { + // Dispatch already consumed $valid so we can drop it here. + if f.name == VALID_FIELD { + return false; + } + // Untagged table has no vlan_id key. + if is_untagged && f.name == VLAN_ID_FIELD { + return false; + } + true + }) + .collect() + } + + /// Translate a `configure_mcastv6` request into a sidecar-lite + /// `set_port_bitmap` addition, holding the replication tracking lock across + /// the entire op. + /// + /// DPD sends `(mcast_grp_a, mcast_grp_b, rid, level1_excl_id, + /// level2_excl_id)`, while sidecar-lite expects `(external, underlay, rid)`, + /// from `McGroupData` membership. On SoftNPU, the bitmap is a write-time + /// snapshot, unlike the live replication engine on the sidecar, allowing + /// recorded group references to recompute later membership changes to the + /// entry. + /// + /// The lock is held from the bitmap snapshot through to the table write on + /// a concurrent membership refresh, which takes the same lock across its + /// own rewrite (see @ `mc_port_add`). + /// + /// This cannot interleave a newer bitmap that this older snapshot would + /// then overwrite. + fn mcast_replication_add( + &self, + hdl: &Handle, + table: String, + keyset_data: Vec, + action_data: &aal::ActionData, + ) -> AsicResult<()> { + let action = &action_data.action; + let external_grp = arg_u16(hdl, action_data, action, "mcast_grp_a")?; + let underlay_grp = arg_u16(hdl, action_data, action, "mcast_grp_b")?; + let rid = arg_u16(hdl, action_data, action, "rid")?; + + let mut mc_data = hdl.mc_data.lock().unwrap(); + let external_bitmap = mc_data.port_bitmap(external_grp); + let underlay_bitmap = mc_data.port_bitmap(underlay_grp); + mc_data.record_repl_entry( + keyset_data.clone(), + external_grp, + underlay_grp, + rid, + ); + + let parameter_data = [ + external_bitmap.to_le_bytes().as_slice(), + underlay_bitmap.to_le_bytes().as_slice(), + rid.to_le_bytes().as_slice(), + ] + .concat(); + + // Logged at debug for the initial bitmap snapshot to be visible in a + // release build. + slog::debug!( + hdl.log, + "mcast replication add: external_grp={external_grp} \ + underlay_grp={underlay_grp} external_bitmap={external_bitmap:#x} \ + underlay_bitmap={underlay_bitmap:#x} rid={rid}" + ); + trace!(hdl.log, "table: {table}"); + trace!(hdl.log, "keyset_data:\n{keyset_data:#?}"); + trace!(hdl.log, "parameter_data:\n{parameter_data:#?}"); + + replace_entry( + hdl, + table, + crate::softnpu::mcast::SET_PORT_BITMAP_ACTION.to_string(), + keyset_data, + parameter_data, + ); + + Ok(()) + } } -// All tables are defined to be 1024 entries deep +fn replace_entry( + hdl: &Handle, + table: String, + action: String, + keyset_data: Vec, + parameter_data: Vec, +) { + let remove = ManagementRequest::TableRemove(TableRemove { + table: table.clone(), + keyset_data: keyset_data.clone(), + }); + crate::softnpu::mgmt::write(remove, &hdl.mgmt_config); + + let add = ManagementRequest::TableAdd(TableAdd { + table, + action, + keyset_data, + parameter_data, + }); + crate::softnpu::mgmt::write(add, &hdl.mgmt_config); +} + +// All tables are defined to be 4096 entries deep. const TABLE_SIZE: usize = 4096; impl TableOps for Table { fn new(hdl: &Handle, type_: TableType) -> AsicResult { - // TODO just mapping sidecar.p4 things onto simplified sidecar-lite.p4 - // things to get started. - let implemented = match type_ { + // Mapping sidecar.p4 table types onto simplified sidecar-lite.p4 + // equivalents. + let (implemented, untagged_id) = match type_ { TableType::RouteIdxIpv4 | TableType::RouteFwdIpv4 | TableType::RouteIdxIpv6 @@ -75,22 +265,39 @@ impl TableOps for Table { | TableType::NatIngressIpv4 | TableType::NatIngressIpv6 | TableType::AttachedSubnetIpv4 - | TableType::AttachedSubnetIpv6 => true, + | TableType::AttachedSubnetIpv6 => (true, None), + TableType::McastIpv6 + | TableType::McastIpv4SrcFilter + | TableType::McastIpv6SrcFilter + | TableType::McastEgressDecapPorts + | TableType::McastEgressPortMapping + | TableType::PortMacAddressMcast => (true, None), + TableType::NatIngressIpv4Mcast => { + (true, Some(MCAST_NAT_V4_UNTAGGED)) + } + TableType::NatIngressIpv6Mcast => { + (true, Some(MCAST_NAT_V6_UNTAGGED)) + } x => { - error!(hdl.log, "TABLE NOT HANDLED {x}"); - false + error!(hdl.log, "table not handled: {x}"); + (false, None) } }; - Ok(Table { type_, implemented, size: TABLE_SIZE }) + Ok(Table { type_, implemented, untagged_id, size: TABLE_SIZE }) } fn size(&self) -> usize { self.size } - fn clear(&self, _hdl: &Handle) -> AsicResult<()> { - //TODO implement in softnpu + fn clear(&self, hdl: &Handle) -> AsicResult<()> { + // TODO: the management protocol has no clear message, meaning + // that entries stay in the pipeline. Send a `TableClear` once + // softnpu and propolis support one. + if self.type_ == TableType::McastIpv6 { + hdl.mc_data.lock().unwrap().clear_repl_entries(); + } Ok(()) } @@ -100,19 +307,43 @@ impl TableOps for Table { key: &M, data: &A, ) -> AsicResult<()> { - let Some(table) = self.softnpu_table_name() else { + let Some(default_table) = self.softnpu_table_name() else { return Ok(()); }; let name = self.type_.to_string(); let match_data = key.key_to_ir().unwrap(); let action_data = data.action_to_ir().unwrap(); + // For tables with VLAN dispatch, resolve which sidecar-lite table + // to target and filter out synthetic match fields. + let (table, fields) = { + let resolved = self + .resolve_vlan_table_id(&match_data.fields) + .unwrap_or(default_table); + let filtered = + self.filter_vlan_match_fields(match_data.fields, resolved); + (resolved.to_string(), filtered) + }; trace!(hdl.log, "entry_add called"); trace!(hdl.log, "table: {name}"); - trace!(hdl.log, "match_data:\n{:#?}", match_data); + trace!(hdl.log, "match_data (filtered): {fields:#?}"); trace!(hdl.log, "action_data:\n{:#?}", action_data); - let keyset_data = keyset_data(match_data.fields, self.type_); + let keyset_data = keyset_data(fields, self.type_); + + // Multicast replication is handled ahead of the generic match so the + // replication tracking lock can be held from the bitmap snapshot through + // to the table write. + if let (TableType::McastIpv6, "configure_mcastv6") = + (self.type_, action_data.action.as_str()) + { + return self.mcast_replication_add( + hdl, + table, + keyset_data, + &action_data, + ); + } let (action, parameter_data) = match ( self.type_, @@ -432,130 +663,90 @@ impl TableOps for Table { | (TableType::NatIngressIpv6, "forward_ipv6_to") | (TableType::AttachedSubnetIpv4, "forward_to_v4") | (TableType::AttachedSubnetIpv6, "forward_to_v6") => { - let mut target = Vec::new(); - let mut vni = Vec::new(); - let mut mac = Vec::new(); + forward_to_sled_params(hdl, &name, action_data)? + } + (TableType::NatIngressIpv4Mcast, "mcast_forward_ipv4_to") + | (TableType::NatIngressIpv6Mcast, "mcast_forward_ipv6_to") => { + forward_to_sled_params(hdl, &name, action_data)? + } + // Multicast source filters: no action parameters. + (TableType::McastIpv4SrcFilter, "allow_source_mcastv4") + | (TableType::McastIpv6SrcFilter, "allow_source_mcastv6") => { + ("allow_source", Vec::new()) + } + // Multicast egress decap: pack 8x32-bit bitmap into 128 bits. + // + // DPD sends set_decap_ports with 8x32-bit bitmap fields + // keyed on the RID. Sidecar-lite expects a single 128-bit + // bitmap. We pack the low 4 chunks (ports 0-127) into a + // u128 for sidecar-lite's bit<128> decap_bitmap field. + (TableType::McastEgressDecapPorts, "set_decap_ports") => { + ("set_decap_ports", pack_decap_bitmap(&action_data)) + } + (TableType::McastEgressDecapPorts, "set_decap_ports_and_vlan") => { + let mut params = pack_decap_bitmap(&action_data); + let vlan_id = arg_u16(hdl, &action_data, &name, "vlan_id")?; + params.extend_from_slice(&vlan_id.to_le_bytes()); + ("set_decap_ports_and_vlan", params) + } + // Multicast egress MAC rewrite. + (TableType::PortMacAddressMcast, "rewrite") => { + let mut params = Vec::new(); for arg in action_data.args { - match arg.name.as_str() { - "target" => { - // "target" is 128 bits - let mut data: Vec = Vec::new(); - match &arg.value { - ValueTypes::U64(_) => { - // Currently the ValueType is always Ptr - error!( - hdl.log, - "expected ValueType::Ptr, \ - received ValueType::U64" - ); - return Ok(()); - } - ValueTypes::Ptr(v) => { - data.extend_from_slice(v.as_slice()); - } - } - let len = data.len(); - let buf = &mut data[len - 16..]; - buf.reverse(); - target.extend_from_slice(buf); - } - "vni" => { - // "vni" is 24 bits - let mut data: Vec = Vec::new(); - match &arg.value { - ValueTypes::U64(v) => { - data.extend_from_slice(&v.to_le_bytes()); - } - ValueTypes::Ptr(_) => { - // Currently the ValueType is always U64 - error!( - hdl.log, - "expected ValueType::U64, \ - received ValueType::Ptr" - ); - return Ok(()); - } - } - vni.extend_from_slice(&data[0..3]); - } - "inner_mac" => { - // "mac" is 48 bits - let mut data: Vec = Vec::new(); - match &arg.value { - ValueTypes::U64(v) => { - data.extend_from_slice(&v.to_le_bytes()); - } - ValueTypes::Ptr(_) => { - // Currently the ValueType is always U64 - error!( - hdl.log, - "expected ValueType::U64, \ - received ValueType::Ptr" - ); - return Ok(()); - } - } - mac.extend_from_slice(&data[0..6]) + match arg.value { + ValueTypes::U64(v) => { + let mac = v.to_le_bytes(); + params.extend_from_slice(&mac[0..6]); } - _ => { - error!(hdl.log, "unknown argument: {}", arg.name); - return Ok(()); + ValueTypes::Ptr(v) => { + params.extend_from_slice(v.as_slice()); } } } - let mut params = Vec::new(); - // arguments currently don't arrive in the correct order, - // so we'll order them manually - params.extend_from_slice(target.as_slice()); - params.extend_from_slice(vni.as_slice()); - params.extend_from_slice(mac.as_slice()); - ("forward_to_sled", params) + ("rewrite_src_mac", params) } + (TableType::McastEgressPortMapping, "set_port_number") => { + ("set_port_number", port_number_param(&action_data)?) + } + // Sidecar-lite's resolvers support drop (by default). + (TableType::ArpIpv4, "drop") + | (TableType::NeighborIpv6, "drop") => ("drop", Vec::new()), + // The local_v4/local_v6 tables have no drop action and dpd programs + // dropv4/dropv6 during port IP setup. + (TableType::PortAddrIpv4, "dropv4") + | (TableType::PortAddrIpv6, "dropv6") => return Ok(()), (_, x) => { - error!(hdl.log, "ACTION NOT HANDLED {name} {x}"); - return Ok(()); + error!(hdl.log, "action not handled: {name} {x}"); + return Err(AsicError::InvalidArg(format!( + "{name}: action {x} not handled by the softnpu backend" + ))); } }; let action = action.to_string(); trace!(hdl.log, "sending request to softnpu"); - trace!(hdl.log, "table: {name}"); + trace!(hdl.log, "table: {table}"); trace!(hdl.log, "action: {:#?}", action); trace!(hdl.log, "keyset_data:\n{:#?}", keyset_data); trace!(hdl.log, "parameter_data:\n{:#?}", parameter_data); - let msg = ManagementRequest::TableAdd(TableAdd { - table: table.to_string(), - action, - keyset_data, - parameter_data, - }); - - crate::softnpu::mgmt::write(msg, &hdl.mgmt_config); + replace_entry(hdl, table, action, keyset_data, parameter_data); Ok(()) } - fn entry_update( + fn entry_update( &self, hdl: &Handle, key: &M, data: &A, ) -> AsicResult<()> { - let Some(_table) = self.softnpu_table_name() else { - return Ok(()); - }; - let name = self.type_.to_string(); - - let match_data = key.key_to_ir().unwrap(); - let action_data = data.action_to_ir().unwrap(); - - trace!(hdl.log, "entry_update called"); - trace!(hdl.log, "table: {name}"); - trace!(hdl.log, "match_data:\n{:#?}", match_data); - trace!(hdl.log, "action_data:\n{:#?}", action_data); - - //TODO implement in softnpu - Ok(()) + // SoftNPU does not currently support in-place updates. `entry_add` + // removes the key before adding it back with the new action data. + // + // Note: this is not an atomic op, as a lookup between the two messages + // could miss the entry, and mgmt IO failures panic in mgmt::write + // rather than surface errors. + self.entry_add(hdl, key, data) } fn entry_del( @@ -563,26 +754,38 @@ impl TableOps for Table { hdl: &Handle, key: &M, ) -> AsicResult<()> { - let Some(table) = self.softnpu_table_name() else { + let Some(default_table) = self.softnpu_table_name() else { return Ok(()); }; let name = self.type_.to_string(); let match_data = key.key_to_ir().unwrap(); + let (table, fields) = { + let resolved = self + .resolve_vlan_table_id(&match_data.fields) + .unwrap_or(default_table); + let filtered = + self.filter_vlan_match_fields(match_data.fields, resolved); + (resolved.to_string(), filtered) + }; trace!(hdl.log, "entry_del called"); trace!(hdl.log, "table: {name}"); - trace!(hdl.log, "match_data:\n{:#?}", match_data); + trace!(hdl.log, "match_data (filtered): {fields:#?}"); + + let keyset_data = keyset_data(fields, self.type_); - let keyset_data = keyset_data(match_data.fields, self.type_); + // Stop tracking a replication entry once it leaves the pipeline so a + // later membership change does not resurrect a stale bitmap. + if self.type_ == TableType::McastIpv6 { + hdl.mc_data.lock().unwrap().forget_repl_entry(&keyset_data); + } trace!(hdl.log, "sending request to softnpu"); - trace!(hdl.log, "table: {name}"); + trace!(hdl.log, "table: {table}"); trace!(hdl.log, "keyset_data:\n{:#?}", keyset_data); - let msg = ManagementRequest::TableRemove(TableRemove { - keyset_data, - table: table.to_string(), - }); + let msg = + ManagementRequest::TableRemove(TableRemove { keyset_data, table }); crate::softnpu::mgmt::write(msg, &hdl.mgmt_config); @@ -606,8 +809,148 @@ impl TableOps for Table { } } +/// Extract the `forward_to_sled` action parameters shared by unicast and +/// multicast NAT/attached-subnet actions. +/// +/// Returns the sidecar-lite action name and serialized parameter bytes. +fn forward_to_sled_params( + hdl: &Handle, + name: &str, + action_data: aal::ActionData, +) -> AsicResult<(&'static str, Vec)> { + for arg in &action_data.args { + if !matches!(arg.name.as_str(), "target" | "vni" | "inner_mac") { + error!(hdl.log, "unknown argument: {} in {name}", arg.name); + return Err(AsicError::InvalidArg(format!( + "{name}: unknown argument {}", + arg.name + ))); + } + } + + let target = arg_ptr(hdl, &action_data, name, "target")?; + let vni = arg_u64(hdl, &action_data, name, "vni")?; + let mac = arg_u64(hdl, &action_data, name, "inner_mac")?; + + let Some(start) = target.len().checked_sub(16) else { + return Err(AsicError::InvalidArg(format!( + "{name}: target is {} bytes, expected at least 16", + target.len() + ))); + }; + + let mut params = Vec::new(); + let mut target_bytes = target[start..].to_vec(); + target_bytes.reverse(); + params.extend_from_slice(&target_bytes); + params.extend_from_slice(&vni.to_le_bytes()[0..3]); + params.extend_from_slice(&mac.to_le_bytes()[0..6]); + Ok(("forward_to_sled", params)) +} + +fn find_arg<'a>( + action_data: &'a aal::ActionData, + action: &str, + arg_name: &str, +) -> AsicResult<&'a ValueTypes> { + action_data + .args + .iter() + .find(|arg| arg.name == arg_name) + .map(|arg| &arg.value) + .ok_or_else(|| { + AsicError::InvalidArg(format!( + "{action}: missing argument {arg_name}" + )) + }) +} + +fn arg_u64( + hdl: &Handle, + action_data: &aal::ActionData, + action: &str, + arg_name: &str, +) -> AsicResult { + match find_arg(action_data, action, arg_name)? { + ValueTypes::U64(v) => Ok(*v), + ValueTypes::Ptr(_) => { + error!(hdl.log, "expected ValueType::U64, received ValueType::Ptr"); + Err(AsicError::InvalidArg(format!( + "{action}: {arg_name} expects ValueType::U64" + ))) + } + } +} + +fn arg_u16( + hdl: &Handle, + action_data: &aal::ActionData, + action: &str, + arg_name: &str, +) -> AsicResult { + let v = arg_u64(hdl, action_data, action, arg_name)?; + u16::try_from(v).map_err(|_| { + AsicError::InvalidArg(format!("{action}: {arg_name} {v} exceeds u16")) + }) +} + +fn arg_ptr<'a>( + hdl: &Handle, + action_data: &'a aal::ActionData, + action: &str, + arg_name: &str, +) -> AsicResult<&'a [u8]> { + match find_arg(action_data, action, arg_name)? { + ValueTypes::Ptr(v) => Ok(v.as_slice()), + ValueTypes::U64(_) => { + error!(hdl.log, "expected ValueType::Ptr, received ValueType::U64"); + Err(AsicError::InvalidArg(format!( + "{action}: {arg_name} expects ValueType::Ptr" + ))) + } + } +} + +/// Pack the low 4 chunks (ports 0-127) of DPD's 8x32-bit decap port +/// bitmap into the 16 bytes of sidecar-lite's bit<128> decap_bitmap +/// field (in little-endian). +/// +/// Caveats: The high chunks are always zero on this backend and ports >= 128 +/// are rejected when adding members. Emitting exactly 16 bytes keeps +/// any following action parameter (the tagged variant's vlan_id) at +/// the offset sidecar-lite reads it from. +fn pack_decap_bitmap(args: &aal::ActionData) -> Vec { + let mut chunks = [0u32; 4]; + for arg in &args.args { + if let ValueTypes::U64(v) = &arg.value { + match arg.name.as_str() { + "ports_0" => chunks[0] = *v as u32, + "ports_1" => chunks[1] = *v as u32, + "ports_2" => chunks[2] = *v as u32, + "ports_3" => chunks[3] = *v as u32, + _ => {} + } + } + } + chunks.iter().flat_map(|c| c.to_le_bytes()).collect() +} + +fn port_number_param(args: &aal::ActionData) -> AsicResult> { + args.args + .iter() + .find(|arg| arg.name == "port_number") + .and_then(|arg| u8::try_from(&arg.value).ok()) + .map(|port_number| vec![port_number]) + .ok_or_else(|| { + AsicError::InvalidArg(format!( + "{}: missing or invalid port_number", + args.action + )) + }) +} + /// Extract keys from `match_data` and ensure that they are -/// in a data structure with the correct length +/// in a data structure with the correct length. fn keyset_data(match_data: Vec, table: TableType) -> Vec { let mut keyset_data: Vec = Vec::new(); for m in match_data { @@ -622,7 +965,7 @@ fn keyset_data(match_data: Vec, table: TableType) -> Vec { keyset_data.extend_from_slice(&data[..4]); } TableType::NeighborIpv6 => { - // "nexthop_ipv4" => bit<128> + // "nexthop_ipv6" => bit<128> let mut buf = Vec::new(); serialize_value_type(&x, &mut buf); buf.reverse(); @@ -655,6 +998,52 @@ fn keyset_data(match_data: Vec, table: TableType) -> Vec { buf.reverse(); keyset_data.extend_from_slice(&buf); } + // Multicast replication: hdr.ipv6.dst => bit<128> + TableType::McastIpv6 => { + let mut buf = Vec::new(); + serialize_value_type(&x, &mut buf); + buf.reverse(); + keyset_data.extend_from_slice(&buf); + } + // Multicast source filter exact keys: inner dst => + // bit<32> or bit<128>. The LPM src key is handled + // in the LPM branch. + TableType::McastIpv4SrcFilter => { + serialize_value_type(&x, &mut data); + keyset_data.extend_from_slice(&data[..4]); + } + TableType::McastIpv6SrcFilter => { + let mut buf = Vec::new(); + serialize_value_type(&x, &mut buf); + buf.reverse(); + keyset_data.extend_from_slice(&buf); + } + // Multicast NAT: dst (bit<32> or bit<128>) plus + // vlan_id (bit<12>, 2 bytes) only when targeting the + // tagged variant. + TableType::NatIngressIpv4Mcast => { + serialize_value_type(&x, &mut data); + let width = if m.name == VLAN_ID_FIELD { 2 } else { 4 }; + keyset_data.extend_from_slice(&data[..width]); + } + TableType::NatIngressIpv6Mcast => { + if m.name == VLAN_ID_FIELD { + serialize_value_type(&x, &mut data); + keyset_data.extend_from_slice(&data[..2]); + } else { + let mut buf = Vec::new(); + serialize_value_type(&x, &mut buf); + buf.reverse(); + keyset_data.extend_from_slice(&buf); + } + } + // Multicast egress tables: port => bit<16> + TableType::McastEgressDecapPorts + | TableType::McastEgressPortMapping + | TableType::PortMacAddressMcast => { + serialize_value_type(&x, &mut data); + keyset_data.extend_from_slice(&data[..2]); + } _ => { serialize_value_type(&x, &mut keyset_data); } @@ -670,7 +1059,13 @@ fn keyset_data(match_data: Vec, table: TableType) -> Vec { serialize_value_type_be(&x.prefix, &mut data); keyset_data.extend_from_slice(&data[data.len() - 4..]); // prefix length for longest prefix match operation - keyset_data.push(x.len as u8) + keyset_data.push(x.len as u8); + } + TableType::McastIpv4SrcFilter => { + // "src_addr" => bit<32> lpm + serialize_value_type_be(&x.prefix, &mut data); + keyset_data.extend_from_slice(&data[data.len() - 4..]); + keyset_data.push(x.len as u8); } _ => { serialize_value_type_be(&x.prefix, &mut keyset_data); @@ -682,7 +1077,7 @@ fn keyset_data(match_data: Vec, table: TableType) -> Vec { MatchEntryValue::Range(x) => { match table { TableType::NatIngressIpv4 | TableType::NatIngressIpv6 => { - // "l4_dst_port" => ingress.nat_id: range => bit<16> + // "l4_dst_port" => ingress.nat_id: range => bit<16> let low = &x.low.to_le_bytes(); let high = &x.high.to_le_bytes(); keyset_data.extend_from_slice(&low[..2]); @@ -725,3 +1120,38 @@ fn serialize_value_type_be(x: &ValueTypes, data: &mut Vec) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_mapping_serializes_for_sidecar_lite() { + let fields = vec![MatchEntryField { + name: "egress_port".to_string(), + value: MatchEntryValue::Value(ValueTypes::U64(0x0102)), + }]; + assert_eq!( + keyset_data(fields, TableType::McastEgressPortMapping), + vec![0x02, 0x01] + ); + + let action_data = aal::ActionData { + action: "set_port_number".to_string(), + args: vec![aal::ActionArg { + name: "port_number".to_string(), + value: ValueTypes::U64(17), + }], + }; + assert_eq!(port_number_param(&action_data).unwrap(), vec![17]); + + let missing = aal::ActionData { + action: "set_port_number".to_string(), + args: Vec::new(), + }; + assert!(matches!( + port_number_param(&missing), + Err(AsicError::InvalidArg(_)) + )); + } +} diff --git a/dpd-client/tests/integration_tests/mcast.rs b/dpd-client/tests/integration_tests/mcast.rs index 17fd0e1a..8c0b302d 100644 --- a/dpd-client/tests/integration_tests/mcast.rs +++ b/dpd-client/tests/integration_tests/mcast.rs @@ -2238,6 +2238,14 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() let ctr_baseline_ingress = switch.get_counter(&port_label_ingress, Some("ingress")).await.unwrap(); + let ctr_baseline_mcast_egress1 = switch + .get_counter(&port_label_egress1, Some("multicast")) + .await + .unwrap(); + let ctr_baseline_mcast_egress2 = switch + .get_counter(&port_label_egress2, Some("multicast")) + .await + .unwrap(); let ctr_baseline_external_egress1 = switch .get_counter(&port_label_egress1, Some("multicast_external")) .await @@ -2267,6 +2275,26 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_external_members() .await .unwrap(); + // One decapped replica should be counted per external member port. + check_counter_incremented( + switch, + &port_label_egress1, + ctr_baseline_mcast_egress1, + 1, + Some("multicast"), + ) + .await + .unwrap(); + check_counter_incremented( + switch, + &port_label_egress2, + ctr_baseline_mcast_egress2, + 1, + Some("multicast"), + ) + .await + .unwrap(); + // Decapped replicas to external members must be attributed to the // external counter, not the underlay one. check_counter_incremented( @@ -2622,6 +2650,22 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe let ctr_baseline_ingress = switch.get_counter(&port_label_ingress, Some("ingress")).await.unwrap(); + let ctr_baseline_mcast_egress1 = switch + .get_counter(&port_label_egress1, Some("multicast")) + .await + .unwrap(); + let ctr_baseline_mcast_egress2 = switch + .get_counter(&port_label_egress2, Some("multicast")) + .await + .unwrap(); + let ctr_baseline_mcast_egress3 = switch + .get_counter(&port_label_egress3, Some("multicast")) + .await + .unwrap(); + let ctr_baseline_mcast_egress4 = switch + .get_counter(&port_label_egress4, Some("multicast")) + .await + .unwrap(); let ctr_baseline_underlay_egress1 = switch .get_counter(&port_label_egress1, Some("multicast_underlay")) .await @@ -2667,6 +2711,24 @@ async fn test_encapped_multicast_geneve_mcast_tag_to_underlay_and_external_membe .await .unwrap(); + // One replica should be counted per member port, external & underlay alike. + for (label, baseline) in [ + (&port_label_egress1, ctr_baseline_mcast_egress1), + (&port_label_egress2, ctr_baseline_mcast_egress2), + (&port_label_egress3, ctr_baseline_mcast_egress3), + (&port_label_egress4, ctr_baseline_mcast_egress4), + ] { + check_counter_incremented( + switch, + label, + baseline, + 1, + Some("multicast"), + ) + .await + .unwrap(); + } + // Still-encapsulated replicas to underlay members count as underlay, // not external, for UNDERLAY_EXTERNAL tagged groups. check_counter_incremented( @@ -7625,6 +7687,98 @@ async fn test_source_filter_update_to_any() -> TestResult { "After update with Any, should have only 1 entry (the /0)" ); + // Narrow to specific sources for (*,G) -> (S,G) transitions, which is + // exercised below with `sources: None`. + let narrow_entry = types::MulticastGroupUpdateExternalEntry { + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), + }, + external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, + sources: Some(vec![ + types::IpSrc::Exact("192.168.1.1".parse().unwrap()), + types::IpSrc::Exact("192.168.1.2".parse().unwrap()), + ]), + }; + + let narrowed = switch + .client + .multicast_group_update_external( + &external_group_ip.try_into().unwrap(), + &make_tag(TEST_TAG), + &narrow_entry, + ) + .await + .expect("Should narrow external group sources") + .into_inner(); + + assert_eq!( + narrowed.sources.as_deref().map(<[_]>::len), + Some(2), + "Narrowing should restore 2 specific sources" + ); + + let after_narrowed_table = switch + .client + .table_dump(SOURCE_FILTER_IPV4_TABLE, false) + .await + .expect("Should dump table after narrowing"); + + assert_eq!( + after_narrowed_table.entries.len(), + baseline_count + 2, + "Narrowing should restore 2 specific entries" + ); + + let keep_entry = types::MulticastGroupUpdateExternalEntry { + internal_forwarding: types::ExternalInternalForwarding { + nat_target: nat_target.clone(), + }, + external_forwarding: types::ExternalForwarding { vlan_id: Some(10) }, + sources: None, + }; + + let kept = switch + .client + .multicast_group_update_external( + &external_group_ip.try_into().unwrap(), + &make_tag(TEST_TAG), + &keep_entry, + ) + .await + .expect("Should update external group with sources: None") + .into_inner(); + + assert_eq!( + kept.sources, narrowed.sources, + "sources: None must preserve the current filter" + ); + + // Round-trip via GET to confirm the stored representation matches. + let fetched = switch + .client + .multicast_group_get(&external_group_ip) + .await + .expect("Should get external group") + .into_inner(); + + assert_eq!( + get_sources(&fetched), + kept.sources, + "GET after sources: None must agree with the update response" + ); + + let after_keep_table = switch + .client + .table_dump(SOURCE_FILTER_IPV4_TABLE, false) + .await + .expect("Should dump table after sources: None"); + + assert_eq!( + after_keep_table.entries.len(), + baseline_count + 2, + "sources: None must keep the 2 specific entries" + ); + // Cleanup in correct order: external first, then internal cleanup_test_group(switch, external_group_ip, TEST_TAG).await.unwrap(); cleanup_test_group(switch, internal_group_ip, TEST_TAG).await diff --git a/dpd/src/mcast/mod.rs b/dpd/src/mcast/mod.rs index 71006f3c..3e02cf3f 100644 --- a/dpd/src/mcast/mod.rs +++ b/dpd/src/mcast/mod.rs @@ -1144,6 +1144,18 @@ pub(crate) fn reset_tag(s: &Switch, tag: &str) -> DpdResult<()> { pub(crate) fn reset(s: &Switch) -> DpdResult<()> { let mut mcast = s.mcast.lock().unwrap(); + // Remove each known group's entries before the table clears below, as + // SoftNPU's `clear` cannot empty its tables. + for group in mcast.groups.values() { + if let Err(e) = delete_group_tables(s, group, &mut Vec::new()) { + error!( + s.log, + "failed to delete table entries for multicast group {}: {e:?}", + group.ip() + ); + } + } + // Destroy ASIC groups let group_ids = s.asic_hdl.mc_domains(); for group_id in group_ids { diff --git a/dpd/src/table/mcast/mod.rs b/dpd/src/table/mcast/mod.rs index 3a34aeb9..c68f5f1b 100644 --- a/dpd/src/table/mcast/mod.rs +++ b/dpd/src/table/mcast/mod.rs @@ -13,12 +13,15 @@ use std::{ net::{Ipv4Addr, Ipv6Addr}, }; -use aal::{ActionParse, MatchParse}; +use aal::{ActionParse, AsicError, MatchParse}; use aal_macros::*; use common::table::TableType; use slog::debug; -use crate::{Switch, types::DpdResult}; +use crate::{ + Switch, + types::{DpdError, DpdResult}, +}; pub(crate) mod mcast_egress; pub(crate) mod mcast_nat; @@ -39,9 +42,19 @@ where M: MatchParse + Hash + fmt::Display, A: ActionParse, { - let keys: Vec = s + let entries = match s .table_get(table)? - .get_entries::(&s.asic_hdl, false)? + .get_entries::(&s.asic_hdl, false) + { + Ok(entries) => entries, + Err(DpdError::Switch(AsicError::OperationUnsupported)) => { + debug!(s.log, "{table} entry listing unsupported, skip clearing"); + return Ok(()); + } + Err(e) => return Err(e), + }; + + let keys: Vec = entries .into_iter() .filter_map(|(key, _)| matches(&key).then_some(key)) .collect();