diff --git a/Cargo.lock b/Cargo.lock index 2ca484ee..a24822ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7398,6 +7398,7 @@ dependencies = [ "colored", "common 0.1.0", "dpd-client 0.1.0", + "dpd-types", "expectorate", "futures", "humantime", diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 0565ced7..dfc29a30 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 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..a17c5268 --- /dev/null +++ b/swadm/src/multicast.rs @@ -0,0 +1,781 @@ +// 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 rear0/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) rear0/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) +//! rear0/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::{ExternalMulticastIp, 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 carrying 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: IpAddr, + }, + /// Create an external multicast group and the associated underlay group + /// that it maps to (1:1). + Add(AddArgs), + /// Delete an external multicast group and the underlay group that it maps to. + Del { + /// External group address (IPv4, or IPv6 outside ff04::/64), or + /// an underlay address with --underlay-only. + group_ip: IpAddr, + /// 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 forwarded packets with. + #[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 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, + )?; + 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 tag = tag + .map(|tag| { + tag.parse::().context("invalid multicast tag") + }) + .transpose()?; + 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. +/// +/// If external creation fails, attempt to remove the 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.unwrap_or_else(|| client.inner().tag.clone()); + let underlay_entry = types::MulticastGroupCreateUnderlayEntry { + group_ip: underlay, + tag: Some(tag), + 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 tag = client + .multicast_group_create_underlay(&underlay_entry) + .await + .with_context(|| { + format!("failed to create underlay multicast group {underlay}") + })? + .into_inner() + .tag; + + let external_entry = types::MulticastGroupCreateExternalEntry { + group_ip, + tag: Some(tag.clone()), + internal_forwarding: types::ExternalInternalForwarding { + nat_target: types::ExternalNatTarget { + internal_ip: underlay, + inner_mac: inner_mac.into(), + vni: types::Vni::from(vni), + }, + }, + external_forwarding: types::ExternalForwarding { vlan_id: vlan }, + sources: (!sources.is_empty()) + .then(|| sources.into_iter().map(types::IpSrc::Exact).collect()), + }; + + let created = client + .multicast_group_create_external(&external_entry) + .await + .with_context(|| { + format!("failed to create external multicast group {group_ip}") + }); + + if let Err(err) = created { + let underlay_ip = IpAddr::from(underlay); + let rollback = async { + let tag: types::MulticastTag = + tag.parse().with_context(|| format!("invalid tag {tag}"))?; + client + .multicast_group_delete(&underlay_ip, &tag) + .await + .with_context(|| { + format!("failed to remove underlay group {underlay}") + }) + }; + + return match rollback.await { + Ok(_) => Err(err), + Err(rollback_err) => Err(anyhow::anyhow!( + "{err:#}; rollback also failed ({rollback_err:#}), leaving \ + underlay group {underlay} behind; remove it with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + )), + }; + } + Ok(()) +} + +fn underlay_for_delete( + group_ip: IpAddr, + group: types::MulticastGroupResponse, + tag: &str, + underlay_only: bool, +) -> anyhow::Result> { + match group { + types::MulticastGroupResponse::External { + internal_forwarding, + tag: group_tag, + .. + } => { + anyhow::ensure!( + !underlay_only, + "cannot use --underlay-only with external multicast group {group_ip}" + ); + + anyhow::ensure!( + group_tag == tag, + "tag mismatch for multicast group {group_ip}: given {tag}, \ + group has {group_tag}" + ); + + Ok(Some(IpAddr::from(internal_forwarding.nat_target.internal_ip))) + } + types::MulticastGroupResponse::Underlay { .. } => { + anyhow::ensure!( + underlay_only, + "{group_ip} is an underlay group; delete the external group that \ + maps to it" + ); + Ok(None) + } + } +} + +fn validate_underlay_for_delete( + underlay: IpAddr, + group: types::MulticastGroupResponse, + tag: &str, +) -> 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, + "tag mismatch for underlay multicast group {underlay}: given {tag}, \ + group has {underlay_tag}" + ); + Ok(()) +} + +async fn multicast_del( + client: &Client, + group_ip: IpAddr, + tag: String, + underlay_only: bool, +) -> anyhow::Result<()> { + let tag_param: types::MulticastTag = + tag.parse().with_context(|| format!("invalid tag {tag}"))?; + + let group = client + .multicast_group_get(&group_ip) + .await + .with_context(|| format!("failed to get multicast group {group_ip}"))? + .into_inner(); + let underlay = underlay_for_delete(group_ip, group, &tag, underlay_only)?; + + if let Some(underlay) = underlay { + let underlay_group = client + .multicast_group_get(&underlay) + .await + .with_context(|| { + format!("failed to get underlay multicast group {underlay}") + })? + .into_inner(); + validate_underlay_for_delete(underlay, underlay_group, &tag)?; + } + + client.multicast_group_delete(&group_ip, &tag_param).await.with_context( + || format!("failed to delete multicast group {group_ip}"), + )?; + + if let Some(underlay) = underlay { + client + .multicast_group_delete(&underlay, &tag_param) + .await + .with_context(|| { + format!( + "failed to delete underlay group {underlay}; retry with \ + `swadm multicast del {underlay} --underlay-only -t {tag}`" + ) + })?; + } + Ok(()) +} + +async fn multicast_get( + client: &Client, + group_ip: IpAddr, +) -> anyhow::Result<()> { + 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, + )?; + 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.unwrap_or_else(|| client.inner().tag.clone()); + 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: types::Vni::from(77_u32), + }, + }, + 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 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", + false, + ) + .expect_err("a mismatched external tag should prevent deletion"); + Ok(()) + } + + #[test] + fn delete_underlay_only_rejects_external() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + underlay_for_delete( + group_ip, + external_group("test_tag")?, + "test_tag", + true, + ) + .expect_err("--underlay-only should reject external groups"); + Ok(()) + } + + #[test] + fn delete_underlay_requires_underlay_only() -> anyhow::Result<()> { + let group_ip = "ff04::1".parse()?; + underlay_for_delete( + group_ip, + underlay_group("test_tag")?, + "test_tag", + false, + ) + .expect_err("underlay deletion should require --underlay-only"); + Ok(()) + } + + #[test] + fn delete_underlay_only_skips_pair_deletion() -> anyhow::Result<()> { + let group_ip = "ff04::1".parse()?; + assert_eq!( + underlay_for_delete( + group_ip, + underlay_group("test_tag")?, + "test_tag", + true, + )?, + None + ); + Ok(()) + } + + #[test] + fn delete_pair_validates_underlay() -> anyhow::Result<()> { + let group_ip = "232.123.45.99".parse()?; + let underlay = "ff04::1".parse()?; + assert_eq!( + underlay_for_delete( + group_ip, + external_group("test_tag")?, + "test_tag", + false, + )?, + Some(underlay) + ); + validate_underlay_for_delete( + underlay, + underlay_group("test_tag")?, + "test_tag", + )?; + + validate_underlay_for_delete( + underlay, + underlay_group("other_tag")?, + "test_tag", + ) + .expect_err("a mismatched underlay tag should prevent deletion"); + + validate_underlay_for_delete( + underlay, + external_group("test_tag")?, + "test_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"); + } +} 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(()) +}