diff --git a/Cargo.lock b/Cargo.lock index f81bccfa..29c5d08f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7526,12 +7526,15 @@ dependencies = [ "colored", "common 0.1.0", "dpd-client 0.1.0", + "dpd-types", + "dpd-types-versions", "expectorate", "futures", "humantime", "oxide-tokio-rt", "oxnet", "predicates", + "proptest", "regex", "reqwest 0.13.5", "serial_test", diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 0565ced7..bb76e8af 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -12,6 +12,7 @@ clap.workspace = true colored.workspace = true common.workspace = true dpd-client.workspace = true +dpd-types.workspace = true futures.workspace = true humantime.workspace = true oxide-tokio-rt.workspace = true @@ -20,10 +21,12 @@ regex.workspace = true reqwest.workspace = true slog.workspace = true tabwriter.workspace = true +thiserror.workspace = true tokio.workspace = true [dev-dependencies] +dpd-types-versions = { workspace = true, features = ["testing"] } expectorate.workspace = true predicates.workspace = true +proptest.workspace = true serial_test.workspace = true -thiserror.workspace = true diff --git a/swadm/src/main.rs b/swadm/src/main.rs index b5fd1221..67026706 100644 --- a/swadm/src/main.rs +++ b/swadm/src/main.rs @@ -23,6 +23,7 @@ mod attached; mod compliance; mod counters; mod link; +mod multicast; mod nat; mod route; mod snapshot; @@ -66,6 +67,11 @@ enum Commands { #[command(subcommand)] cmd: nat::Nat, }, + #[clap(visible_alias = "mcast")] + Multicast { + #[command(subcommand)] + cmd: multicast::Multicast, + }, #[clap(visible_alias = "attsub")] AttachedSubnet { #[command(subcommand)] @@ -243,6 +249,9 @@ async fn main_impl() -> anyhow::Result<()> { attached::attsub_cmd(&client, p).await } Commands::Nat { cmd: p } => nat::nat_cmd(&client, p).await, + Commands::Multicast { cmd } => { + multicast::multicast_cmd(&client, cmd).await + } Commands::Counters { cmd: c } => counters::ctrs_cmd(&client, c).await, Commands::SwitchPort { cmd: p } => { switchport::switch_cmd(&client, p).await diff --git a/swadm/src/multicast.rs b/swadm/src/multicast.rs new file mode 100644 index 00000000..2d075ac6 --- /dev/null +++ b/swadm/src/multicast.rs @@ -0,0 +1,1301 @@ +// 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 + +//! Inspection, creation, and deletion of the multicast groups programmed on +//! the switch. +//! +//! An example session against a running dpd is shown below. Each `add` creates +//! an external group along with the underlay group it maps to. By default, +//! `del` checks both tags, then deletes the external group followed by its +//! underlay: +//! +//! ```text +//! $ swadm multicast add 232.123.45.99 -i ff04::1 \ +//! --member rear0/0:underlay --member qsfp0/0:external \ +//! -m 33:33:00:00:00:01 -v 77 --vlan 10 -s 10.0.0.1 -s 10.0.0.2 \ +//! -t oxide-demo +//! $ swadm multicast add 224.0.1.50 -i ff04::2 \ +//! -m 33:33:00:00:00:02 -v 88 -t oxide-demo +//! ``` +//! +//! ```text +//! $ swadm multicast list -t oxide-demo +//! Group IP Kind Ext Group ID UL Group ID Tag Detail +//! 224.0.1.50 external 65532 - oxide-demo nat=ff04::2 mac=33:33:00:00:00:02 vni=88 vlan=- src=any +//! 232.123.45.99 external 65534 - oxide-demo nat=ff04::1 mac=33:33:00:00:00:01 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +//! ff04::1 underlay 65534 65533 oxide-demo rear0/0(underlay) qsfp0/0(external) +//! ff04::2 underlay 65532 65531 oxide-demo - +//! ``` +//! +//! ```text +//! $ swadm multicast get 232.123.45.99 +//! Group IP: 232.123.45.99 +//! Kind: external +//! External group ID: 65534 +//! Tag: oxide-demo +//! NAT target: ff04::1 (mac 33:33:00:00:00:01, vni 77) +//! VLAN: 10 +//! Sources: 10.0.0.1,10.0.0.2 +//! ``` +//! +//! ```text +//! $ swadm multicast get ff04::1 +//! Group IP: ff04::1 +//! Kind: underlay +//! External group ID: 65534 +//! Underlay group ID: 65533 +//! Tag: oxide-demo +//! Members: +//! rear0/0(underlay) +//! qsfp0/0(external) +//! ``` +//! +//! ```text +//! $ swadm multicast del ff04::1 -t oxide-demo +//! Error: ff04::1 is an underlay group; delete the external group that maps to it +//! $ swadm multicast del 224.0.1.50 -t oxide-demo +//! $ swadm multicast del 232.123.45.99 -t oxide-demo +//! ``` +//! +//! If deleting the external group succeeds but deleting its underlay fails, +//! retry the underlay deletion: +//! +//! ```text +//! $ swadm multicast del ff04::1 --underlay-only -t oxide-demo +//! ``` + +use std::fmt; +use std::io::{Write, stdout}; +use std::net::IpAddr; +use std::str::FromStr; + +use anyhow::Context; +use clap::{Args, Subcommand, ValueEnum}; +use colored::Colorize; +use futures::stream::{StreamExt, TryStreamExt}; +use tabwriter::TabWriter; + +use common::network::{MacAddr, Vni}; +use dpd_client::{Client, ClientInfo, types}; +use dpd_types::mcast::{ + ExactSource, ExternalMulticastIp, ExternalMulticastIpError, MulticastTag, + SourceEntry, SourceFilter, UnderlayMulticastIpv6, +}; + +use crate::LinkPath; + +/// Replication kind for a multicast group, matching the `Kind` column. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum GroupKind { + /// Groups with a NAT target and no direct members. + External, + /// Groups in the reserved underlay subnet (ff04::/64) that replicate + /// to member ports. + Underlay, +} + +#[derive(Debug, Subcommand)] +/// Inspect, create, and delete the multicast groups programmed on the switch. +pub enum Multicast { + /// List multicast groups, optionally filtered by tag. + #[clap(visible_alias = "ls")] + List { + /// Limit the listing to groups with the given tag. + #[clap(short = 't', long)] + tag: Option, + /// Limit the listing to external or underlay groups. + #[clap(short = 'k', long = "kind")] + kind: Option, + }, + /// Show the full configuration of a single multicast group. + Get { + /// Group IP address (IPv4, external IPv6, or underlay IPv6). + group_ip: GroupAddr, + }, + /// Create an external multicast group and the associated underlay group + /// that it maps to (1:1). + Add(AddArgs), + /// Delete an external multicast group as well as the underlay group that + /// it maps to, or just the unreferenced underlay group with + /// --underlay-only. + Del { + /// External group address (IPv4, or IPv6 outside ff04::/64), or + /// an underlay address with --underlay-only. + group_ip: GroupAddr, + /// The tag the groups were created with; defaults to the client tag. + #[clap(short = 't', long)] + tag: Option, + /// Delete only an unreferenced underlay group. + #[clap(long)] + underlay_only: bool, + }, +} + +#[derive(Debug, Args)] +pub struct AddArgs { + /// External group address (IPv4, or IPv6 outside ff04::/64). + group_ip: ExternalMulticastIp, + /// Underlay group address, within the internal ff04::/64 block. + #[clap(short = 'i', long)] + underlay: UnderlayMulticastIpv6, + /// Member described as `port/link:direction`, where direction is underlay + /// or external. + /// + /// Repeat for each member. + #[clap(long = "member")] + members: Vec, + /// Inner MAC address for the NAT target. + #[clap(short = 'm', long = "mac")] + inner_mac: MacAddr, + /// Geneve VNI for the NAT target. + #[clap(short = 'v', long)] + vni: Vni, + /// VLAN ID to tag on decapsulated packet copies sent to external members. + #[clap(long)] + vlan: Option, + /// Source address to filter on. + /// + /// Repeat for each source. Each source must match the group's + /// address family. SSM requires at least one source; omitting this option + /// accepts any source for ASM. + #[clap(short = 's', long = "source")] + sources: Vec, + /// Tag to create both groups with; defaults to the client tag. + #[clap(short = 't', long)] + tag: Option, +} + +/// A multicast group address that is either an underlay (`ff04::/64`) or +/// external. +#[derive(Debug, Clone, Copy)] +pub enum GroupAddr { + /// An external multicast group address. + External(ExternalMulticastIp), + /// An underlay group address within `ff04::/64`. + Underlay(UnderlayMulticastIpv6), +} + +/// Errors from parsing a [`GroupAddr`]. +#[derive(Debug, thiserror::Error)] +pub enum GroupAddrParseError { + /// The input is not an IP address. + #[error(transparent)] + InvalidIp(#[from] std::net::AddrParseError), + /// The address is neither an underlay group nor a valid external group. + #[error( + "{0}; expected an external multicast group or an underlay group \ + within ff04::/64" + )] + InvalidGroup(#[from] ExternalMulticastIpError), +} + +impl FromStr for GroupAddr { + type Err = GroupAddrParseError; + + fn from_str(s: &str) -> Result { + let ip: IpAddr = s.parse()?; + if let IpAddr::V6(v6) = ip + && let Ok(underlay) = UnderlayMulticastIpv6::new(v6) + { + return Ok(Self::Underlay(underlay)); + } + Ok(Self::External(ExternalMulticastIp::new(ip)?)) + } +} + +impl From for IpAddr { + fn from(addr: GroupAddr) -> Self { + match addr { + GroupAddr::External(ip) => ip.into(), + GroupAddr::Underlay(ip) => ip.into(), + } + } +} + +impl fmt::Display for GroupAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + IpAddr::from(*self).fmt(f) + } +} + +fn tag_or_client_default( + client: &Client, + tag: Option, +) -> anyhow::Result { + match tag { + Some(tag) => Ok(tag), + None => { + let tag = &client.inner().tag; + tag.parse().with_context(|| format!("invalid client tag {tag}")) + } + } +} + +/// A group member parsed from `port/link:direction`. +#[derive(Debug, Clone)] +pub struct MemberArg { + link: LinkPath, + direction: types::Direction, +} + +impl FromStr for MemberArg { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let (link, direction) = s.split_once(':').with_context(|| { + format!("expected port/link:direction, got {s}") + })?; + let direction = match direction { + "underlay" => Some(types::Direction::Underlay), + "external" => Some(types::Direction::External), + _ => None, + } + .with_context(|| { + format!( + "invalid direction {direction}: expected underlay or external" + ) + })?; + Ok(Self { link: link.parse()?, direction }) + } +} + +struct DirectionLabel<'a>(&'a types::Direction); + +impl fmt::Display for DirectionLabel<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + types::Direction::Underlay => f.write_str("underlay"), + types::Direction::External => f.write_str("external"), + } + } +} + +struct MembersSummary<'a>(&'a [types::MulticastGroupMember]); + +impl fmt::Display for MembersSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + return f.write_str("-"); + } + let members = self + .0 + .iter() + .map(|member| { + format!( + "{}/{}({})", + member.port_id, + *member.link_id, + DirectionLabel(&member.direction), + ) + }) + .collect::>() + .join(" "); + f.write_str(&members) + } +} + +struct SourcesSummary<'a>(Option<&'a [types::IpSrc]>); + +impl fmt::Display for SourcesSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(sources) if !sources.is_empty() => { + let sources = sources + .iter() + .map(|source| match source { + types::IpSrc::Exact(ip) => ip.to_string(), + types::IpSrc::Any => "any".to_string(), + }) + .collect::>() + .join(","); + f.write_str(&sources) + } + _ => f.write_str("any"), + } + } +} + +struct ExternalSummary<'a> { + internal_forwarding: &'a types::ExternalInternalForwarding, + external_forwarding: &'a types::ExternalForwarding, + sources: Option<&'a [types::IpSrc]>, +} + +impl fmt::Display for ExternalSummary<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let nat_target = &self.internal_forwarding.nat_target; + write!( + f, + "nat={} mac={} vni={}", + nat_target.internal_ip, + nat_target.inner_mac, + nat_target.vni.as_u32(), + )?; + match self.external_forwarding.vlan_id { + Some(v) => write!(f, " vlan={v}")?, + None => f.write_str(" vlan=-")?, + } + write!(f, " src={}", SourcesSummary(self.sources)) + } +} + +async fn multicast_list( + client: &Client, + tag: Option, + kind: Option, +) -> anyhow::Result<()> { + let mut groups = match &tag { + Some(tag) => { + client.multicast_groups_list_by_tag_stream(tag, None).boxed() + } + None => client.multicast_groups_list_stream(None).boxed(), + }; + + let mut tw = TabWriter::new(stdout()); + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}\t{}", + "Group IP".underline(), + "Kind".underline(), + "Ext Group ID".underline(), + "UL Group ID".underline(), + "Tag".underline(), + "Detail".underline(), + )?; + + while let Some(group) = + groups.try_next().await.context("failed to list multicast groups")? + { + if !matches!( + (kind, &group), + (None, _) + | ( + Some(GroupKind::External), + types::MulticastGroupResponse::External { .. } + ) + | ( + Some(GroupKind::Underlay), + types::MulticastGroupResponse::Underlay { .. } + ) + ) { + continue; + } + match &group { + types::MulticastGroupResponse::Underlay { + group_ip, + external_group_id, + underlay_group_id, + tag, + members, + } => writeln!( + &mut tw, + "{}\tunderlay\t{}\t{}\t{}\t{}", + group_ip, + external_group_id, + underlay_group_id, + tag, + MembersSummary(members), + )?, + types::MulticastGroupResponse::External { + group_ip, + external_group_id, + tag, + internal_forwarding, + external_forwarding, + sources, + } => writeln!( + &mut tw, + "{}\texternal\t{}\t-\t{}\t{}", + group_ip, + external_group_id, + tag, + ExternalSummary { + internal_forwarding, + external_forwarding, + sources: sources.as_deref(), + }, + )?, + } + } + + tw.flush()?; + Ok(()) +} + +/// Create both an underlay group and its associated external group with a +/// shared tag. +/// +/// # Rollback +/// +/// A failed creation step removes what this command created: +/// +/// - Underlay creation fails with a dpd rollback failure: dpd kept the +/// underlay group, and it is removed. +/// - External creation fails: the underlay group is removed. When dpd reports +/// a rollback failure, it kept the external group, and that group is removed +/// first. +/// - External creation fails without a dpd response: the external group is +/// removed first only if a read-back shows it has this tag and targets +/// the new underlay group. +/// +/// This preserves the initial creation error and includes any cleanup failure. +async fn multicast_add(client: &Client, args: AddArgs) -> anyhow::Result<()> { + let AddArgs { + group_ip, + underlay, + members, + inner_mac, + vni, + vlan, + sources, + tag, + } = args; + let tag = tag_or_client_default(client, tag)?; + let source_entries = + sources.into_iter().map(SourceEntry::Exact).collect::>(); + let sources = SourceFilter::from_entries(group_ip, Some(&source_entries))?; + let underlay_entry = types::MulticastGroupCreateUnderlayEntry { + group_ip: underlay, + tag: Some(String::from(tag.clone())), + members: members + .into_iter() + .map(|m| types::MulticastGroupMember { + port_id: m.link.port_id, + link_id: m.link.link_id, + direction: m.direction, + }) + .collect(), + }; + + let underlay_context = + || format!("failed to create underlay multicast group {underlay}"); + + match client.multicast_group_create_underlay(&underlay_entry).await { + Ok(_) => {} + Err(err) + if has_error_code( + &err, + dpd_client::ROLLBACK_FAILURE_ERROR_CODE, + ) => + { + let err = anyhow::Error::new(err).context(underlay_context()); + let cleanup = remove_underlay(client, underlay, &tag).await; + return Err(with_cleanup_failure(err, "cleanup", cleanup)); + } + Err(err) + if !matches!( + err, + dpd_client::Error::ErrorResponse(_) + | dpd_client::Error::InvalidRequest(_) + ) => + { + return Err(anyhow::Error::new(err).context(format!( + "{}; dpd may have created underlay group {underlay}. If \ + `swadm multicast get {underlay}` shows tag {tag}, remove \ + the group with `swadm multicast del {underlay} \ + --underlay-only -t {tag}`", + underlay_context() + ))); + } + Err(err) => { + return Err(anyhow::Error::new(err).context(underlay_context())); + } + } + + let external_entry = types::MulticastGroupCreateExternalEntry { + group_ip, + tag: Some(String::from(tag.clone())), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: underlay, + inner_mac: inner_mac.into(), + vni, + }, + }, + external_forwarding: types::ExternalForwarding { vlan_id: vlan }, + sources: sources.exact_sources().map(|sources| { + sources + .iter() + .map(|source| types::IpSrc::Exact(source.ip())) + .collect() + }), + }; + + let created = client.multicast_group_create_external(&external_entry).await; + + if let Err(err) = created { + let failure = ExternalCreateFailure::from(&err); + let err = anyhow::Error::new(err).context(format!( + "failed to create external multicast group {group_ip}" + )); + let rollback = + multicast_add_rollback(client, failure, group_ip, underlay, &tag) + .await; + return Err(with_cleanup_failure(err, "rollback", rollback)); + } + Ok(()) +} + +fn with_cleanup_failure( + err: anyhow::Error, + what: &str, + cleanup: anyhow::Result<()>, +) -> anyhow::Error { + cleanup + .err() + .map(|cleanup_err| { + anyhow::anyhow!("{err:#}; {what} also failed ({cleanup_err:#})") + }) + .unwrap_or(err) +} + +fn has_error_code(err: &dpd_client::Error, code: &str) -> bool { + matches!( + err, + dpd_client::Error::ErrorResponse(response) + if response.error_code.as_deref() == Some(code) + ) +} + +fn is_not_found(err: &dpd_client::Error) -> bool { + err.status() == Some(reqwest::StatusCode::NOT_FOUND) +} + +/// Whether a failed external create left the external group on the switch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExternalCreateFailure { + /// dpd rejected the request without keeping the group. + NotRetained, + /// dpd's unwind failed and it kept the group, marked for repair. + Retained, + /// No dpd error response arrived, so the group may or may not exist. + Unknown, +} + +impl From<&dpd_client::Error> for ExternalCreateFailure { + fn from(err: &dpd_client::Error) -> Self { + match err { + _ if has_error_code( + err, + dpd_client::ROLLBACK_FAILURE_ERROR_CODE, + ) => + { + Self::Retained + } + dpd_client::Error::ErrorResponse(_) + | dpd_client::Error::InvalidRequest(_) => Self::NotRetained, + _ => Self::Unknown, + } + } +} + +/// Whether `group` is the external group this `add` would have created. +/// +/// The underlay was created by the same command, so only an external group +/// with this tag and that underlay NAT target can belong to it. +fn created_by_this_add( + group: &types::MulticastGroupResponse, + underlay: UnderlayMulticastIpv6, + tag: &MulticastTag, +) -> bool { + matches!( + group, + types::MulticastGroupResponse::External { + internal_forwarding, + tag: group_tag, + .. + } if group_tag == tag.as_ref() + && internal_forwarding.nat_target.internal_ip == underlay + ) +} + +async fn reconcile_external_create( + client: &Client, + group_ip: ExternalMulticastIp, + underlay: UnderlayMulticastIpv6, + tag: &MulticastTag, +) -> ExternalCreateFailure { + match multicast_group_for_delete(client, group_ip.into()).await { + Ok(group) if created_by_this_add(&group, underlay, tag) => { + ExternalCreateFailure::Retained + } + Ok(_) => ExternalCreateFailure::NotRetained, + Err(e) + if e.downcast_ref::>() + .is_some_and(is_not_found) => + { + ExternalCreateFailure::NotRetained + } + Err(_) => ExternalCreateFailure::Unknown, + } +} + +async fn multicast_add_rollback( + client: &Client, + failure: ExternalCreateFailure, + group_ip: ExternalMulticastIp, + underlay: UnderlayMulticastIpv6, + tag: &MulticastTag, +) -> anyhow::Result<()> { + let failure = match failure { + ExternalCreateFailure::Unknown => { + reconcile_external_create(client, group_ip, underlay, tag).await + } + known => known, + }; + + match failure { + ExternalCreateFailure::Retained => { + match client.multicast_group_delete(&group_ip.into(), tag).await { + Err(e) if is_not_found(&e) => {} + removed => { + removed.with_context(|| { + format!( + "failed to remove external group {group_ip}; it \ + and underlay group {underlay} remain, remove \ + both with `swadm multicast del {group_ip} -t \ + {tag}`" + ) + })?; + } + } + } + ExternalCreateFailure::NotRetained | ExternalCreateFailure::Unknown => { + } + } + + match client.multicast_group_delete(&underlay.into(), tag).await { + Err(e) if is_not_found(&e) => Ok(()), + Err(e) + if has_error_code( + &e, + dpd_client::MCAST_GROUP_REFERENCED_ERROR_CODE, + ) => + { + Err(e).with_context(|| { + format!( + "failed to remove underlay group {underlay}; external \ + group {group_ip} may reference it. If `swadm multicast \ + get {group_ip}` shows NAT target {underlay}, remove both \ + with `swadm multicast del {group_ip} -t {tag}`" + ) + }) + } + removed => removed.map(|_| ()).with_context(|| { + format!( + "failed to remove underlay group {underlay}; it remains, \ + remove it with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + ) + }), + } +} + +async fn remove_underlay( + client: &Client, + underlay: UnderlayMulticastIpv6, + tag: &MulticastTag, +) -> anyhow::Result<()> { + match client.multicast_group_delete(&underlay.into(), tag).await { + Err(e) if is_not_found(&e) => Ok(()), + removed => removed.map(|_| ()).with_context(|| { + format!( + "failed to remove underlay group {underlay}; it remains, \ + remove it with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + ) + }), + } +} + +fn underlay_for_delete( + group_ip: ExternalMulticastIp, + group: types::MulticastGroupResponse, + tag: &MulticastTag, +) -> anyhow::Result { + match group { + types::MulticastGroupResponse::External { + internal_forwarding, + tag: group_tag, + .. + } => { + anyhow::ensure!( + group_tag == tag.as_ref(), + "tag mismatch for multicast group {group_ip}: given {tag}, \ + group has {group_tag}" + ); + + Ok(internal_forwarding.nat_target.internal_ip) + } + types::MulticastGroupResponse::Underlay { .. } => { + anyhow::bail!("multicast group {group_ip} is not an external group") + } + } +} + +fn validate_underlay_for_delete( + underlay: UnderlayMulticastIpv6, + group: types::MulticastGroupResponse, + tag: &MulticastTag, +) -> anyhow::Result<()> { + let underlay_tag = match group { + types::MulticastGroupResponse::Underlay { tag, .. } => tag, + types::MulticastGroupResponse::External { .. } => { + anyhow::bail!( + "multicast group {underlay} is not an underlay group" + ); + } + }; + + anyhow::ensure!( + underlay_tag == tag.as_ref(), + "tag mismatch for underlay multicast group {underlay}: given {tag}, \ + group has {underlay_tag}" + ); + Ok(()) +} + +async fn multicast_del( + client: &Client, + addr: GroupAddr, + tag: MulticastTag, + underlay_only: bool, +) -> anyhow::Result<()> { + let group_ip = match (addr, underlay_only) { + (GroupAddr::Underlay(underlay), true) => { + client + .multicast_group_delete(&underlay.into(), &tag) + .await + .with_context(|| { + format!("failed to delete multicast group {underlay}") + })?; + return Ok(()); + } + (GroupAddr::External(group_ip), true) => anyhow::bail!( + "cannot use --underlay-only with external multicast group \ + {group_ip}" + ), + (GroupAddr::Underlay(underlay), false) => anyhow::bail!( + "{underlay} is an underlay group; delete the external group that \ + maps to it" + ), + (GroupAddr::External(group_ip), false) => group_ip, + }; + + let external_group = + multicast_group_for_delete(client, group_ip.into()).await?; + let underlay = underlay_for_delete(group_ip, external_group, &tag)?; + + let underlay_group = + multicast_group_for_delete(client, underlay.into()).await?; + validate_underlay_for_delete(underlay, underlay_group, &tag)?; + + client.multicast_group_delete(&group_ip.into(), &tag).await.with_context( + || format!("failed to delete multicast group {group_ip}"), + )?; + + client.multicast_group_delete(&underlay.into(), &tag).await.with_context( + || { + format!( + "failed to delete underlay group {underlay}; retry with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + ) + }, + )?; + Ok(()) +} + +async fn multicast_group_for_delete( + client: &Client, + group_ip: IpAddr, +) -> anyhow::Result { + let err = match client.multicast_group_get(&group_ip).await { + Ok(group) => return Ok(group.into_inner()), + Err(err) => err, + }; + + if has_error_code(&err, dpd_client::MCAST_REPAIR_PENDING_ERROR_CODE) { + let mut groups = client.multicast_groups_list_stream(None); + while let Some(group) = groups + .try_next() + .await + .context("failed to list multicast groups pending repair")? + { + let addr = match &group { + types::MulticastGroupResponse::External { + group_ip, .. + } => IpAddr::from(*group_ip), + types::MulticastGroupResponse::Underlay { + group_ip, .. + } => IpAddr::from(*group_ip), + }; + + if addr == group_ip { + return Ok(group); + } + + if addr > group_ip { + break; + } + } + } + + Err(err) + .with_context(|| format!("failed to get multicast group {group_ip}")) +} + +async fn multicast_get( + client: &Client, + group: GroupAddr, +) -> anyhow::Result<()> { + let group_ip = IpAddr::from(group); + let group = client + .multicast_group_get(&group_ip) + .await + .with_context(|| format!("failed to get multicast group {group_ip}"))? + .into_inner(); + + let mut tw = TabWriter::new(stdout()); + match group { + types::MulticastGroupResponse::Underlay { + group_ip, + external_group_id, + underlay_group_id, + tag, + members, + } => { + writeln!(&mut tw, "Group IP:\t{group_ip}")?; + writeln!(&mut tw, "Kind:\tunderlay")?; + writeln!(&mut tw, "External group ID:\t{external_group_id}")?; + writeln!(&mut tw, "Underlay group ID:\t{underlay_group_id}")?; + writeln!(&mut tw, "Tag:\t{tag}")?; + writeln!(&mut tw, "Members:")?; + if members.is_empty() { + writeln!(&mut tw, " (none)")?; + } + for member in &members { + writeln!( + &mut tw, + " {}/{}({})", + member.port_id, + *member.link_id, + DirectionLabel(&member.direction), + )?; + } + } + types::MulticastGroupResponse::External { + group_ip, + external_group_id, + tag, + internal_forwarding, + external_forwarding, + sources, + } => { + writeln!(&mut tw, "Group IP:\t{group_ip}")?; + writeln!(&mut tw, "Kind:\texternal")?; + writeln!(&mut tw, "External group ID:\t{external_group_id}")?; + writeln!(&mut tw, "Tag:\t{tag}")?; + let nat_target = &internal_forwarding.nat_target; + writeln!( + &mut tw, + "NAT target:\t{} (mac {}, vni {})", + nat_target.internal_ip, + nat_target.inner_mac, + nat_target.vni.as_u32(), + )?; + match external_forwarding.vlan_id { + Some(v) => writeln!(&mut tw, "VLAN:\t{v}")?, + None => writeln!(&mut tw, "VLAN:\t(none)")?, + } + writeln!( + &mut tw, + "Sources:\t{}", + SourcesSummary(sources.as_deref()) + )?; + } + } + + tw.flush()?; + Ok(()) +} + +pub async fn multicast_cmd( + client: &Client, + cmd: Multicast, +) -> anyhow::Result<()> { + match cmd { + Multicast::List { tag, kind } => { + multicast_list(client, tag, kind).await + } + Multicast::Get { group_ip } => multicast_get(client, group_ip).await, + Multicast::Add(args) => multicast_add(client, args).await, + Multicast::Del { group_ip, tag, underlay_only } => { + let tag = tag_or_client_default(client, tag)?; + multicast_del(client, group_ip, tag, underlay_only).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn external_group( + tag: &str, + ) -> anyhow::Result { + Ok(types::MulticastGroupResponse::External { + group_ip: "232.123.45.99".parse()?, + external_group_id: 65534_u16, + tag: tag.to_string(), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: "ff04::1".parse()?, + inner_mac: "11:22:33:44:55:66".parse::()?.into(), + vni: Vni::new(77)?, + }, + }, + external_forwarding: types::ExternalForwarding { + vlan_id: Some(10), + }, + sources: Some(vec![types::IpSrc::Exact("10.0.0.1".parse()?)]), + }) + } + + fn underlay_group( + tag: &str, + ) -> anyhow::Result { + Ok(types::MulticastGroupResponse::Underlay { + group_ip: "ff04::1".parse()?, + external_group_id: 65534_u16, + underlay_group_id: 65533_u16, + tag: tag.to_string(), + members: vec![], + }) + } + + #[test] + fn group_addr_parses_underlay_and_external() -> anyhow::Result<()> { + for addr in ["ff04::1", "ff04::ffff:1"] { + assert!(matches!(addr.parse()?, GroupAddr::Underlay(_)), "{addr}"); + } + + for addr in ["232.123.45.99", "224.1.2.3", "ff0e::1", "ff04:0:0:1::1"] { + assert!(matches!(addr.parse()?, GroupAddr::External(_)), "{addr}"); + } + + for addr in ["10.0.0.1", "ff02::1", "not-an-ip"] { + addr.parse::() + .expect_err("non-group addresses should be rejected"); + } + Ok(()) + } + + #[tokio::test] + async fn add_validates_sources_before_underlay() -> anyhow::Result<()> { + use dpd_types::mcast::MulticastGroupCreateExternalError; + + let client = Client::new_with_client( + "http://127.0.0.1:0", + reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(1)) + .build()?, + dpd_client::ClientState { + tag: "test_tag".to_string(), + log: slog::Logger::root(slog::Discard, slog::o!()), + }, + ); + + for (group, source) in [ + ("232.123.45.99", None), + ("ff3e::8000:1234", None), + ("224.1.2.3", Some("2001:db8::1")), + ("ff0e::1", Some("10.0.0.1")), + ("232.123.45.99", Some("2001:db8::1")), + ("ff3e::8000:1234", Some("10.0.0.1")), + ] { + let group_ip: ExternalMulticastIp = group.parse()?; + let source = source + .map(|source| source.parse::()) + .transpose()?; + let expected = match source { + Some(source) => { + MulticastGroupCreateExternalError::SourceFamilyMismatch { + source_ip: source.ip(), + group: group_ip.into(), + } + } + None => MulticastGroupCreateExternalError::SsmRequiresSources( + group_ip.into(), + ), + }; + let err = multicast_add( + &client, + AddArgs { + group_ip, + underlay: "ff04::1".parse()?, + members: vec![], + inner_mac: "11:22:33:44:55:66".parse()?, + vni: Vni::new(77)?, + vlan: None, + sources: source.into_iter().collect(), + tag: None, + }, + ) + .await + .expect_err("invalid sources should fail before creating a group"); + + assert_eq!( + err.downcast_ref::(), + Some(&expected), + "{group} with source {source:?}: {err:#}", + ); + } + Ok(()) + } + + #[test] + fn delete_external_requires_matching_tag() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + underlay_for_delete( + group_ip, + external_group("other_tag")?, + &"test_tag".parse()?, + ) + .expect_err("a mismatched external tag should prevent deletion"); + Ok(()) + } + + #[test] + fn delete_external_rejects_underlay_response() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + underlay_for_delete( + group_ip, + underlay_group("test_tag")?, + &"test_tag".parse()?, + ) + .expect_err("an external address resolving to an underlay group"); + Ok(()) + } + + #[test] + fn delete_pair_validates_underlay() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + let underlay: UnderlayMulticastIpv6 = "ff04::1".parse()?; + let tag: MulticastTag = "test_tag".parse()?; + assert_eq!( + underlay_for_delete(group_ip, external_group("test_tag")?, &tag)?, + underlay + ); + validate_underlay_for_delete( + underlay, + underlay_group("test_tag")?, + &tag, + )?; + + validate_underlay_for_delete( + underlay, + underlay_group("other_tag")?, + &tag, + ) + .expect_err("a mismatched underlay tag should prevent deletion"); + + validate_underlay_for_delete( + underlay, + external_group("test_tag")?, + &tag, + ) + .expect_err("pair deletion should require an underlay group"); + Ok(()) + } + + #[test] + fn member_arg_parses_direction() -> anyhow::Result<()> { + let member: MemberArg = "int0/0:external".parse()?; + assert!(matches!(member.direction, types::Direction::External)); + let member: MemberArg = "int0/0:underlay".parse()?; + assert!(matches!(member.direction, types::Direction::Underlay)); + Ok(()) + } + + #[test] + fn member_arg_rejects_bad_input() { + "int0/0".parse::().expect_err("a member needs a direction"); + "int0/0:sideways" + .parse::() + .expect_err("direction must be underlay or external"); + } + + fn error_response( + status: reqwest::StatusCode, + error_code: Option<&str>, + ) -> dpd_client::Error { + dpd_client::Error::ErrorResponse(dpd_client::ResponseValue::new( + types::Error { + error_code: error_code.map(str::to_string), + message: "test".to_string(), + request_id: "test".to_string(), + }, + status, + reqwest::header::HeaderMap::new(), + )) + } + + #[test] + fn external_create_failure_from_responses() { + let unwind = error_response( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + Some(dpd_client::ROLLBACK_FAILURE_ERROR_CODE), + ); + assert_eq!( + ExternalCreateFailure::from(&unwind), + ExternalCreateFailure::Retained + ); + + for err in [ + error_response(reqwest::StatusCode::CONFLICT, None), + error_response(reqwest::StatusCode::BAD_REQUEST, None), + error_response( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + Some(dpd_client::MCAST_REPAIR_PENDING_ERROR_CODE), + ), + dpd_client::Error::InvalidRequest("unsent".to_string()), + ] { + assert_eq!( + ExternalCreateFailure::from(&err), + ExternalCreateFailure::NotRetained, + "{err:?} must not trigger external group removal" + ); + } + + assert_eq!( + ExternalCreateFailure::from(&dpd_client::Error::Custom( + "hook".to_string() + )), + ExternalCreateFailure::Unknown + ); + } + + #[test] + fn reconcile_matches_only_this_adds_group() -> anyhow::Result<()> { + let ours: UnderlayMulticastIpv6 = "ff04::1".parse()?; + let tag: MulticastTag = "test_tag".parse()?; + assert!(created_by_this_add(&external_group("test_tag")?, ours, &tag)); + + let other_underlay: UnderlayMulticastIpv6 = "ff04::2".parse()?; + assert!( + !created_by_this_add( + &external_group("test_tag")?, + other_underlay, + &tag + ), + "a pre-existing group targeting another underlay is not ours" + ); + assert!( + !created_by_this_add(&external_group("other_tag")?, ours, &tag), + "a group with another tag is not ours" + ); + assert!( + !created_by_this_add(&underlay_group("test_tag")?, ours, &tag), + "an underlay group is never the external group we created" + ); + Ok(()) + } + + mod proptests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use dpd_types_versions::latest::mcast::{UnicastAddrV4, UnicastAddrV6}; + use dpd_types_versions::testing::{ + ipv4_asm_group_strategy, ipv4_ssm_group_strategy, + ipv6_asm_group_strategy, ipv6_ssm_group_strategy, + }; + use proptest::prelude::*; + + use super::*; + + fn external_group_strategy() -> impl Strategy { + prop_oneof![ + ipv4_asm_group_strategy().prop_map(IpAddr::V4), + ipv4_ssm_group_strategy().prop_map(IpAddr::V4), + ipv6_asm_group_strategy().prop_map(IpAddr::V6), + ipv6_ssm_group_strategy().prop_map(IpAddr::V6), + ] + } + + fn unicast_source_strategy() -> impl Strategy { + prop_oneof![ + any::().prop_map(IpAddr::from), + any::().prop_map(IpAddr::from), + ] + } + + fn port_strategy() -> impl Strategy { + prop_oneof![ + Just("int0".to_string()), + (0u8..32).prop_map(|n| format!("rear{n}")), + (0u8..32).prop_map(|n| format!("qsfp{n}")), + ] + } + + proptest! { + #[test] + fn group_addr_parses_external_groups( + addr in external_group_strategy(), + ) { + let parsed: GroupAddr = addr.to_string().parse().unwrap(); + prop_assert!(matches!(parsed, GroupAddr::External(_))); + prop_assert_eq!(IpAddr::from(parsed), addr); + prop_assert_eq!(parsed.to_string(), addr.to_string()); + } + + #[test] + fn group_addr_parses_underlay_groups( + underlay in any::(), + ) { + let addr = IpAddr::from(underlay); + let parsed: GroupAddr = addr.to_string().parse().unwrap(); + prop_assert!(matches!(parsed, GroupAddr::Underlay(_))); + prop_assert_eq!(IpAddr::from(parsed), addr); + prop_assert_eq!(parsed.to_string(), addr.to_string()); + } + + #[test] + fn group_addr_rejects_unicast(addr in unicast_source_strategy()) { + prop_assert!(addr.to_string().parse::().is_err()); + } + + #[test] + fn member_arg_parses( + port in port_strategy(), + link in 0u8..8, + external in any::(), + ) { + let direction = if external { "external" } else { "underlay" }; + let member: MemberArg = + format!("{port}/{link}:{direction}").parse().unwrap(); + prop_assert_eq!(member.link.port_id.to_string(), port); + prop_assert_eq!(*member.link.link_id, link); + prop_assert_eq!( + DirectionLabel(&member.direction).to_string(), + direction + ); + } + } + + #[test] + fn group_addr_rejects_unspecified() { + for addr in [ + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + ] { + addr.to_string() + .parse::() + .expect_err("unspecified addresses are not groups"); + } + } + } +} diff --git a/swadm/tests/cli/cmd.rs b/swadm/tests/cli/cmd.rs index 20d60e80..f4ff802c 100644 --- a/swadm/tests/cli/cmd.rs +++ b/swadm/tests/cli/cmd.rs @@ -72,7 +72,16 @@ pub fn swadm_exact( .map(Cow::from) .unwrap_or_else(|_| common::DEFAULT_DPD_PORT.to_string().into()); + // CI exports RUST_BACKTRACE=1, which forces anyhow to append a "Stack + // backtrace:" section to every error swadm prints, and stderr would + // differ from a local run and never match an expectation file. + // + // We still keep panic backtraces but turn off the library-level ones, + // just like omdb's test harness does: + // https://github.com/oxidecomputer/omicron/blob/654472b6368b48743f34c0744e2acfb06278f796/dev-tools/omdb/tests/test_all_output.rs#L961-L969 let output = Command::new(self::SWADM) + .env("RUST_BACKTRACE", "1") + .env("RUST_LIB_BACKTRACE", "0") .args(["--host", host.as_ref(), "--port", port.as_ref()]) .args(input) .output()?; @@ -135,10 +144,40 @@ pub mod re { use regex::Regex; use std::sync::LazyLock; - /// A block of parentheses with characters inside. Doesn't + /// A block of parentheses with characters inside. This doesn't /// support nested parentheses. pub static PARENS: LazyLock = LazyLock::new(|| Regex::new(r"\([^)]*\)").unwrap().into()); + + /// Matches the multicast group IDs that dpd allocates, as printed out by + /// `swadm multicast get` (the "group ID:" lines) and `swadm multicast + /// list` (the two ID columns). Each capture group holds one ID, or `-` + /// when the group is lacking one. + /// + /// This is an example with captured spans, marked with `^`, in each output + /// form: + /// + /// ```text + /// External group ID: 65534 + /// ^^^^^ + /// 232.123.45.99 external 65534 - test nat=... + /// ^^^^^ ^ + /// ff04::5ad:1 underlay 65534 65533 test int0/0(...) + /// ^^^^^ ^^^^^ + /// ``` + /// + /// The regex crate has no lookaround operation, which means that the match + /// also incorporates the surrounding text (the label, or the group IP and + /// kind) in order to anchor each ID. + /// + /// Use [`Output::mask`](super::Output::mask) to replace only the captures. + pub static GROUP_ID: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?m)^(?:(?:External|Underlay) group ID: +(\d+)$|\S+ +(?:external|underlay) +(\d+|-) +(\d+|-) )", + ) + .unwrap() + .into() + }); } /// A wrapper type for regex inputs that can be @@ -230,6 +269,40 @@ impl Output { self } + /// Replaces the digits in each numeric capture group of the pattern with + /// `#` of the same width, keeping the column alignment intact. + pub fn mask(&mut self, reg: impl AsRef) -> &mut Self { + *self.as_mut() = reg + .as_ref() + .replace_all(self.as_ref(), |caps: ®ex::Captures<'_>| { + let matched = caps.get(0).unwrap(); + let id_spans: Vec<_> = caps + .iter() + .skip(1) + .flatten() + .filter(|group| { + group.as_str().bytes().all(|b| b.is_ascii_digit()) + }) + .map(|group| group.range()) + .collect(); + + matched + .as_str() + .char_indices() + .map(|(offset, ch)| { + let pos = matched.start() + offset; + if id_spans.iter().any(|span| span.contains(&pos)) { + '#' + } else { + ch + } + }) + .collect::() + }) + .to_string(); + self + } + /// Keeps only those lines of output text for which the pattern matches. /// /// This reformats line separators into a single newline. @@ -296,11 +369,51 @@ impl TryFrom for Output { #[cfg(test)] mod test { + use super::Output; use super::re; /// Verify these unwraps don't panic. #[test] fn pattern_statics() { let _ = &*re::PARENS; + let _ = &*re::GROUP_ID; + } + + #[test] + fn mask_group_ids_in_list() { + let list = "\ +Group IP Kind Ext Group ID UL Group ID Tag Detail +232.123.45.99 external 65534 - test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=777 vlan=100 src=10.0.0.1 +ff04::5ad:1 underlay 65534 65533 test int0/0(underlay) +"; + let expected = "\ +Group IP Kind Ext Group ID UL Group ID Tag Detail +232.123.45.99 external ##### - test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=777 vlan=100 src=10.0.0.1 +ff04::5ad:1 underlay ##### ##### test int0/0(underlay) +"; + let mut out = Output::Stdout(list.to_string()); + out.mask(&*re::GROUP_ID); + assert_eq!(AsRef::::as_ref(&out), expected); + } + + #[test] + fn mask_group_ids_in_get() { + let get = "\ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: 65534 +Underlay group ID: 65533 +Tag: test +"; + let expected = "\ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: ##### +Underlay group ID: ##### +Tag: test +"; + let mut out = Output::Stdout(get.to_string()); + out.mask(&*re::GROUP_ID); + assert_eq!(AsRef::::as_ref(&out), expected); } } diff --git a/swadm/tests/cli/expect/multicast_add_duplicate.txt b/swadm/tests/cli/expect/multicast_add_duplicate.txt new file mode 100644 index 00000000..9495637b --- /dev/null +++ b/swadm/tests/cli/expect/multicast_add_duplicate.txt @@ -0,0 +1,4 @@ +Error: failed to create external multicast group 232.123.45.99 + +Caused by: + Error Response: status: 409 Conflict; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_add_underlay_group.txt b/swadm/tests/cli/expect/multicast_add_underlay_group.txt new file mode 100644 index 00000000..753ac6c7 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_add_underlay_group.txt @@ -0,0 +1 @@ +error: invalid value 'ff04::5ad:1' for ' \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt new file mode 100644 index 00000000..e2b9f85a --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_external_tag_mismatch.txt @@ -0,0 +1 @@ +Error: tag mismatch for multicast group 232.123.45.99: given other_tag, group has swadm_multicast_test diff --git a/swadm/tests/cli/expect/multicast_del_underlay.txt b/swadm/tests/cli/expect/multicast_del_underlay.txt new file mode 100644 index 00000000..f1e71924 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay.txt @@ -0,0 +1 @@ +Error: ff04::5ad:1 is an underlay group; delete the external group that maps to it diff --git a/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt b/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt new file mode 100644 index 00000000..afbb5015 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay_only_external.txt @@ -0,0 +1 @@ +Error: cannot use --underlay-only with external multicast group 232.123.45.99 diff --git a/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt b/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt new file mode 100644 index 00000000..8413ab19 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_del_underlay_referenced.txt @@ -0,0 +1,4 @@ +Error: failed to delete multicast group ff04::5ad:1 + +Caused by: + Error Response: status: 400 Bad Request; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_default_tag.txt b/swadm/tests/cli/expect/multicast_get_default_tag.txt new file mode 100644 index 00000000..e9214e35 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_default_tag.txt @@ -0,0 +1,7 @@ +Group IP: 224.0.1.51 +Kind: external +External group ID: ##### +Tag: cli +NAT target: ff04::5ad:4 (mac 11:22:33:44:55:66, vni 77) +VLAN: (none) +Sources: any diff --git a/swadm/tests/cli/expect/multicast_get_external.txt b/swadm/tests/cli/expect/multicast_get_external.txt new file mode 100644 index 00000000..8645def7 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_external.txt @@ -0,0 +1,7 @@ +Group IP: 232.123.45.99 +Kind: external +External group ID: ##### +Tag: swadm_multicast_test +NAT target: ff04::5ad:1 (mac 11:22:33:44:55:66, vni 77) +VLAN: 10 +Sources: 10.0.0.1,10.0.0.2 diff --git a/swadm/tests/cli/expect/multicast_get_external_asm.txt b/swadm/tests/cli/expect/multicast_get_external_asm.txt new file mode 100644 index 00000000..eb9cea7a --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_external_asm.txt @@ -0,0 +1,7 @@ +Group IP: 224.0.1.50 +Kind: external +External group ID: ##### +Tag: swadm_multicast_test +NAT target: ff04::5ad:2 (mac 11:22:33:44:55:66, vni 77) +VLAN: (none) +Sources: any diff --git a/swadm/tests/cli/expect/multicast_get_not_found.txt b/swadm/tests/cli/expect/multicast_get_not_found.txt new file mode 100644 index 00000000..7137e6c2 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group 232.123.45.99 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_not_found_spare.txt b/swadm/tests/cli/expect/multicast_get_not_found_spare.txt new file mode 100644 index 00000000..70de4711 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found_spare.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group ff04::5ad:3 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt b/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt new file mode 100644 index 00000000..c5c2d7f3 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_not_found_underlay.txt @@ -0,0 +1,4 @@ +Error: failed to get multicast group ff04::5ad:1 + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/multicast_get_underlay.txt b/swadm/tests/cli/expect/multicast_get_underlay.txt new file mode 100644 index 00000000..ddb1941f --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_underlay.txt @@ -0,0 +1,8 @@ +Group IP: ff04::5ad:1 +Kind: underlay +External group ID: ##### +Underlay group ID: ##### +Tag: swadm_multicast_test +Members: + int0/0(underlay) + int0/0(external) diff --git a/swadm/tests/cli/expect/multicast_get_underlay_empty.txt b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt new file mode 100644 index 00000000..326862b7 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_get_underlay_empty.txt @@ -0,0 +1,7 @@ +Group IP: ff04::5ad:2 +Kind: underlay +External group ID: ##### +Underlay group ID: ##### +Tag: swadm_multicast_test +Members: + (none) diff --git a/swadm/tests/cli/expect/multicast_list.txt b/swadm/tests/cli/expect/multicast_list.txt new file mode 100644 index 00000000..1c237e77 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list.txt @@ -0,0 +1,5 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +224.0.1.50 external ##### - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external ##### - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 +ff04::5ad:1 underlay ##### ##### swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay ##### ##### swadm_multicast_test - diff --git a/swadm/tests/cli/expect/multicast_list_tag_external.txt b/swadm/tests/cli/expect/multicast_list_tag_external.txt new file mode 100644 index 00000000..b6894ebe --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_tag_external.txt @@ -0,0 +1,3 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +224.0.1.50 external ##### - swadm_multicast_test nat=ff04::5ad:2 mac=11:22:33:44:55:66 vni=77 vlan=- src=any +232.123.45.99 external ##### - swadm_multicast_test nat=ff04::5ad:1 mac=11:22:33:44:55:66 vni=77 vlan=10 src=10.0.0.1,10.0.0.2 diff --git a/swadm/tests/cli/expect/multicast_list_tag_none.txt b/swadm/tests/cli/expect/multicast_list_tag_none.txt new file mode 100644 index 00000000..43f89a66 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_tag_none.txt @@ -0,0 +1 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail diff --git a/swadm/tests/cli/expect/multicast_list_underlay.txt b/swadm/tests/cli/expect/multicast_list_underlay.txt new file mode 100644 index 00000000..c886aa50 --- /dev/null +++ b/swadm/tests/cli/expect/multicast_list_underlay.txt @@ -0,0 +1,3 @@ +Group IP Kind Ext Group ID UL Group ID Tag Detail +ff04::5ad:1 underlay ##### ##### swadm_multicast_test int0/0(underlay) int0/0(external) +ff04::5ad:2 underlay ##### ##### swadm_multicast_test - diff --git a/swadm/tests/cli/main.rs b/swadm/tests/cli/main.rs index 722a07a6..7dbcf9f4 100644 --- a/swadm/tests/cli/main.rs +++ b/swadm/tests/cli/main.rs @@ -7,4 +7,5 @@ mod cmd; mod counters; mod link_apply; +mod multicast; mod tx_eq; diff --git a/swadm/tests/cli/multicast.rs b/swadm/tests/cli/multicast.rs new file mode 100644 index 00000000..f90d5121 --- /dev/null +++ b/swadm/tests/cli/multicast.rs @@ -0,0 +1,322 @@ +// 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 + +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; + +use anyhow::Context; +use serial_test::serial; + +use crate::cmd; + +const TEST_TAG: &str = "swadm_multicast_test"; + +const EXT_IPV4: Ipv4Addr = Ipv4Addr::new(232, 123, 45, 99); +const EXT_IPV4_ASM: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 50); +const EXT_SOURCE: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +const EXT_SOURCE_2: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); +const EXT_IPV4_DEFAULT_TAG: Ipv4Addr = Ipv4Addr::new(224, 0, 1, 51); +const EXT_VLAN: u16 = 10; +const EXT_VNI: u32 = 77; + +const UNDERLAY_IPV6: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 1); +const UNDERLAY_IPV6_EMPTY: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 2); +const UNDERLAY_IPV6_SPARE: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 3); +const UNDERLAY_IPV6_DEFAULT_TAG: Ipv6Addr = + Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0x5ad, 4); + +const MEMBER_LINK: &str = "int0/0"; +const INNER_MAC: &str = "11:22:33:44:55:66"; + +/// Delete this test's set of groups, and then (re-)create them with +/// `swadm multicast add`. +/// +/// Members here keep to a fixed link so that the rendered +/// `port/link(direction)` remains stable across runs. Each external group +/// targets its own underlay group because dpd enforces a 1:1 mapping between +/// an external group and its underlay NAT target. +fn create_groups() -> anyhow::Result<()> { + delete_groups(); + + cmd::swadm(format!("link get {MEMBER_LINK}")) + .with_context(|| format!("member link {MEMBER_LINK} is missing"))?; + + for args in [ + format!( + "multicast add {EXT_IPV4} -i {UNDERLAY_IPV6} \ + --member {MEMBER_LINK}:underlay --member {MEMBER_LINK}:external \ + -m {INNER_MAC} -v {EXT_VNI} --vlan {EXT_VLAN} -s {EXT_SOURCE} \ + -s {EXT_SOURCE_2} -t {TEST_TAG}" + ), + format!( + "multicast add {EXT_IPV4_ASM} --underlay {UNDERLAY_IPV6_EMPTY} \ + --mac {INNER_MAC} --vni {EXT_VNI} --tag {TEST_TAG}" + ), + ] { + cmd::swadm(&args).with_context(|| format!("swadm {args}"))?; + } + + Ok(()) +} + +fn delete_groups() { + for group in [EXT_IPV4, EXT_IPV4_ASM] { + let _ = cmd::swadm(format!("multicast del {group} -t {TEST_TAG}")); + } + let _ = cmd::swadm(format!("multicast del {EXT_IPV4_DEFAULT_TAG}")); + let _ = cmd::swadm(format!( + "multicast del {UNDERLAY_IPV6_DEFAULT_TAG} --underlay-only" + )); + for group in [UNDERLAY_IPV6, UNDERLAY_IPV6_EMPTY, UNDERLAY_IPV6_SPARE] { + let _ = cmd::swadm(format!( + "multicast del {group} --underlay-only -t {TEST_TAG}" + )); + } +} + +#[test] +#[serial] +#[ignore] +fn multicast_list() -> anyhow::Result<()> { + create_groups()?; + + for (args, file) in [ + (format!("multicast list -t {TEST_TAG}"), "multicast_list.txt"), + ( + format!("multicast list -t {TEST_TAG} --kind underlay"), + "multicast_list_underlay.txt", + ), + ( + format!("multicast list -t {TEST_TAG} -k external"), + "multicast_list_tag_external.txt", + ), + ( + "multicast ls -t no_such_tag".to_string(), + "multicast_list_tag_none.txt", + ), + ] { + cmd::swadm(args)?.mask(&*cmd::re::GROUP_ID).try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_get() -> anyhow::Result<()> { + create_groups()?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V4(EXT_IPV4_ASM), "multicast_get_external_asm.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + (IpAddr::V6(UNDERLAY_IPV6_EMPTY), "multicast_get_underlay_empty.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_get_not_found() -> anyhow::Result<()> { + delete_groups(); + + let err = cmd::swadm(format!("multicast get {EXT_IPV4}")) + .expect_err("get on a missing group should fail"); + + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_get_not_found.txt") +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_removes_pair() -> anyhow::Result<()> { + create_groups()?; + + cmd::swadm(format!("multicast del {EXT_IPV4} -t {TEST_TAG}"))?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_not_found.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_not_found_underlay.txt"), + ] { + let err = cmd::swadm(format!("multicast get {group}")) + .expect_err("deleted group should be gone"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_external_tag_mismatch() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!("multicast del {EXT_IPV4} -t other_tag")) + .expect_err("a mismatched external tag should prevent deletion"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_external_tag_mismatch.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_underlay_only_external() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!( + "multicast del {EXT_IPV4} --underlay-only -t {TEST_TAG}" + )) + .expect_err("--underlay-only should reject external groups"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_underlay_only_external.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_del_rejects_underlay() -> anyhow::Result<()> { + create_groups()?; + + let err = + cmd::swadm(format!("multicast del {UNDERLAY_IPV6} -t {TEST_TAG}")) + .expect_err("del on an underlay group should fail"); + cmd::Output::try_from(err)? + .try_expectorate("multicast_del_underlay.txt")?; + + let err = cmd::swadm(format!( + "multicast del {UNDERLAY_IPV6} --underlay-only -t {TEST_TAG}" + )) + .expect_err("a referenced underlay group should not be deleted"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_del_underlay_referenced.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +#[serial] +#[ignore] +fn multicast_add_rolls_back_underlay() -> anyhow::Result<()> { + create_groups()?; + + let err = cmd::swadm(format!( + "multicast add {EXT_IPV4} -i {UNDERLAY_IPV6_SPARE} \ + -m {INNER_MAC} -v {EXT_VNI} -s {EXT_SOURCE} -t {TEST_TAG}" + )) + .expect_err("add on an existing external group should fail"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_add_duplicate.txt")?; + + let err = cmd::swadm(format!("multicast get {UNDERLAY_IPV6_SPARE}")) + .expect_err("rolled-back underlay group should be gone"); + cmd::Output::try_from(err)? + .trunc_at("headers:") + .try_expectorate("multicast_get_not_found_spare.txt")?; + + for (group, file) in [ + (IpAddr::V4(EXT_IPV4), "multicast_get_external.txt"), + (IpAddr::V6(UNDERLAY_IPV6), "multicast_get_underlay.txt"), + ] { + cmd::swadm(format!("multicast get {group}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate(file)?; + } + + delete_groups(); + Ok(()) +} + +#[test] +fn multicast_add_rejects_underlay_group() -> anyhow::Result<()> { + let err = cmd::swadm(format!( + "multicast add {UNDERLAY_IPV6} -i {UNDERLAY_IPV6_EMPTY} \ + -m {INNER_MAC} -v {EXT_VNI}" + )) + .expect_err("an underlay address is not a valid external group"); + cmd::Output::try_from(err)? + .trunc_at("': ") + .try_expectorate("multicast_add_underlay_group.txt") +} + +#[test] +#[serial] +#[ignore] +fn multicast_add_del_default_tag() -> anyhow::Result<()> { + delete_groups(); + + cmd::swadm(format!( + "multicast add {EXT_IPV4_DEFAULT_TAG} -i {UNDERLAY_IPV6_DEFAULT_TAG} \ + -m {INNER_MAC} -v {EXT_VNI}" + ))?; + cmd::swadm(format!("multicast get {EXT_IPV4_DEFAULT_TAG}"))? + .mask(&*cmd::re::GROUP_ID) + .try_expectorate("multicast_get_default_tag.txt")?; + + cmd::swadm(format!("multicast del {EXT_IPV4_DEFAULT_TAG}"))?; + for group in [ + IpAddr::V4(EXT_IPV4_DEFAULT_TAG), + IpAddr::V6(UNDERLAY_IPV6_DEFAULT_TAG), + ] { + cmd::swadm(format!("multicast get {group}")) + .expect_err("del without -t should remove both groups"); + } + + Ok(()) +}