Skip to content

Commit 9cdf741

Browse files
[multicast] M2P forwarding, OPTE port subscription, and sled-agent propagation
This completes the multicast data path by adding per-sled M2P (multicast-to- physical) mapping, forwarding entry management, and OPTE port subscription for multicast group members. ## Sled-agent + API update(s) - Add multicast endpoints at API v33 (MCAST_M2P_FORWARDING) for M2P, forwarding, and per-VMM subscribe/unsubscribe - Version v7 join/leave endpoints to v7..v33 with shim conversion - Move multicast types from omicron-common to sled-agent-types-versions v33 module (mcast_m2p_forwarding) with re-exports through sled-agent-types - OPTE port_manager gains set/clear operations for M2P and forwarding - Port subscription cleanup on PortTicket release - Consolidate per-port mutable state (eip_gateways, mcast) into PortState - Seed eip_gateways from global map on port creation to prevent stale gateway state on newly created ports - Lock ordering documented for ports, routes, eip_gateways ## Nexus - New `sled.rs` (MulticastSledClient) encapsulating all sled-agent multicast interactions: subscribe/unsubscribe, M2P/forwarding propagation and teardown - Groups RPW propagates M2P and forwarding entries to all member sleds after DPD configuration, with convergent retry on failure - Members RPW uses MemberReconcileCtx to thread shared reconciliation state. Handles subscribe on join, unsubscribe on leave, and re-subscribe on migration - `subscribe_vmm` gracefully handles missing propolis (mirrors unsubscribe) - `lookup_propolis_id` returns Ok(None) for missing instance - `lookup_and_update_member_sled_id` surfaces DB errors instead of swallowing them - Order-independent forwarding comparison to avoid spurious dataplane churn; always create forwarding entries for active groups even with empty next-hops - Dataplane client updated for bifurcated replication groups ## illumos-utils - Remove CIDR allow rules for multicast (handled by OPTE gateway layer) - Reject Reserved replication mode in `list_mcast_fwd` with InvalidMcastForwardingState error - Consolidate error variants into InvalidMcastUnderlay ## Tests - Integration tests for M2P/forwarding/subscribe lifecycle - Instance migration multicast re-convergence
1 parent 936a327 commit 9cdf741

40 files changed

Lines changed: 4419 additions & 933 deletions

.github/buildomat/jobs/deploy.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ ptime -m tar xvzf /input/package/work/package.tar.gz
182182
source .github/buildomat/ci-env.sh
183183

184184
# Source the OPTE override (if any) from the canonical location and apply it.
185+
#
185186
# When set, install the override p5p from buildomat instead of using the
186187
# version baked into the ramdisk image. The version must be pinned explicitly
187188
# because IPS version ordering does not match semver.

.github/workflows/check-opte-ver.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
name: check-opte-ver
22
on:
33
pull_request:
4-
paths:
5-
- '.github/workflows/check-opte-ver.yml'
6-
- 'Cargo.toml'
7-
- 'tools/opte_version'
8-
- 'tools/opte_version_override'
4+
branches: [main]
95
jobs:
106
check-opte-ver:
117
runs-on: ubuntu-22.04
@@ -19,7 +15,12 @@ jobs:
1915
run: cargo install toml-cli@0.2.3
2016
- name: Check OPTE version and rev match
2117
run: ./tools/ci_check_opte_ver.sh
18+
19+
# Runs on every PR regardless of paths changed, since the override
20+
# file could have been set in an earlier commit and slip through on
21+
# an unrelated PR otherwise.
2222
check-opte-override:
23+
if: github.base_ref == 'main'
2324
runs-on: ubuntu-22.04
2425
steps:
2526
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

dev-tools/releng/src/main.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ async fn main() -> Result<()> {
629629
.arg("-o") // output directory for image
630630
.arg(args.output_dir.join(format!("os-{}", target)))
631631
.arg("-F") // pass extra image builder features
632-
.arg(format!("optever={}", opte_version))
632+
.arg(format!("optever={opte_version}"))
633633
.arg("-P") // include all files from extra proto area
634634
.arg(proto_dir.join("root"))
635635
.arg("-N") // image name
@@ -690,7 +690,7 @@ async fn main() -> Result<()> {
690690
// When OPTE_COMMIT is set, download the override p5p from buildomat
691691
// and add it as a package source for the image build.
692692
if let Some(ov) = &opte_override {
693-
let p5p_path = tempdir.path().join(format!("opte-{}.p5p", target));
693+
let p5p_path = tempdir.path().join(format!("opte-{target}.p5p"));
694694
let commit = ov.commit.clone();
695695
let dest = p5p_path.clone();
696696
let cl = client.clone();
@@ -702,17 +702,17 @@ async fn main() -> Result<()> {
702702

703703
image_cmd = image_cmd
704704
.arg("-p")
705-
.arg(format!("helios-dev=file://{}", p5p_path,));
705+
.arg(format!("helios-dev=file://{p5p_path}"));
706+
}
706707

707-
jobs.push_command(format!("{target}-image"), image_cmd)
708-
.after("helios-setup")
709-
.after("helios-incorp")
710-
.after(format!("{target}-opte-p5p"));
711-
} else {
712-
jobs.push_command(format!("{target}-image"), image_cmd)
713-
.after("helios-setup")
714-
.after("helios-incorp")
715-
.after(format!("{target}-proto"));
708+
let image_job = jobs
709+
.push_command(format!("{target}-image"), image_cmd)
710+
.after("helios-setup")
711+
.after("helios-incorp")
712+
.after(format!("{target}-proto"));
713+
714+
if opte_override.is_some() {
715+
image_job.after(format!("{target}-opte-p5p"));
716716
}
717717
}
718718
// Build the recovery target after we build the host target. Only one

illumos-utils/src/opte/illumos.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use opte_ioctl::OpteHdl;
1313
use slog::Logger;
1414
use slog::info;
1515
use std::net::IpAddr;
16+
use std::net::Ipv6Addr;
1617

1718
#[derive(thiserror::Error, Debug)]
1819
pub enum Error {
@@ -70,6 +71,11 @@ pub enum Error {
7071
"Tried to update attached subnets on non-existent port ({0}, {1:?})"
7172
)]
7273
AttachedSubnetUpdateMissingPort(uuid::Uuid, NetworkInterfaceKind),
74+
75+
#[error(
76+
"address {0} is not within the underlay multicast subnet (ff04::/64)"
77+
)]
78+
InvalidMcastUnderlay(Ipv6Addr),
7379
}
7480

7581
/// Delete all xde devices on the system.

illumos-utils/src/opte/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ use oxnet::IpNet;
3333
use oxnet::Ipv4Net;
3434
use oxnet::Ipv6Net;
3535
pub use port::Port;
36-
pub use port_manager::MulticastGroupCfg;
3736
pub use port_manager::PortCreateParams;
3837
pub use port_manager::PortManager;
3938
pub use port_manager::PortTicket;
39+
pub use sled_agent_types::multicast::MulticastGroupCfg;
4040
use std::net::IpAddr;
4141
use std::net::Ipv4Addr;
4242
use std::net::Ipv6Addr;

illumos-utils/src/opte/non_illumos.rs

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,38 @@
22
// License, v. 2.0. If a copy of the MPL was not distributed with this
33
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5-
//! Mock / dummy versions of the OPTE module, for non-illumos platforms
5+
//! Mock / dummy versions of the OPTE module, for non-illumos platforms.
6+
//!
7+
//! Most methods are either `unimplemented!()` or silent no-ops.
8+
//! Multicast subscribe/unsubscribe is an exception, as it maintains real
9+
//! in-memory state because port manager tests assert on subscription contents.
610
711
use crate::addrobj::AddrObject;
812
use omicron_common::api::internal::shared::NetworkInterfaceKind;
913
use oxide_vpc::api::AddRouterEntryReq;
14+
use oxide_vpc::api::ClearMcast2PhysReq;
15+
use oxide_vpc::api::ClearMcastForwardingReq;
1016
use oxide_vpc::api::ClearVirt2PhysReq;
1117
use oxide_vpc::api::DelRouterEntryReq;
1218
use oxide_vpc::api::DetachSubnetResp;
13-
use oxide_vpc::api::Direction;
19+
use oxide_vpc::api::DumpMcast2PhysResp;
20+
use oxide_vpc::api::DumpMcastForwardingResp;
1421
use oxide_vpc::api::DumpVirt2PhysResp;
1522
use oxide_vpc::api::IpCfg;
1623
use oxide_vpc::api::IpCidr;
1724
use oxide_vpc::api::ListPortsResp;
25+
use oxide_vpc::api::McastSubscribeReq;
26+
use oxide_vpc::api::McastUnsubscribeReq;
1827
use oxide_vpc::api::NoResp;
1928
use oxide_vpc::api::PortInfo;
2029
use oxide_vpc::api::RouterClass;
2130
use oxide_vpc::api::RouterTarget;
2231
use oxide_vpc::api::SetExternalIpsReq;
2332
use oxide_vpc::api::SetFwRulesReq;
33+
use oxide_vpc::api::SetMcast2PhysReq;
34+
use oxide_vpc::api::SetMcastForwardingReq;
2435
use oxide_vpc::api::SetVirt2PhysReq;
36+
use oxide_vpc::api::SourceFilter;
2537
use oxide_vpc::api::VpcCfg;
2638
use slog::Logger;
2739
use std::collections::HashMap;
@@ -76,6 +88,11 @@ pub enum Error {
7688
"Tried to update attached subnets on non-existent port ({0}, {1:?})"
7789
)]
7890
AttachedSubnetUpdateMissingPort(uuid::Uuid, NetworkInterfaceKind),
91+
92+
#[error(
93+
"address {0} is not within the underlay multicast subnet (ff04::/64)"
94+
)]
95+
InvalidMcastUnderlay(std::net::Ipv6Addr),
7996
}
8097

8198
pub fn initialize_xde_driver(
@@ -172,6 +189,8 @@ pub(crate) struct PortData {
172189
pub port: PortInfo,
173190
/// The routes for this port. This simulates the router layer.
174191
pub routes: Vec<RouteInfo>,
192+
/// Multicast group subscriptions: group IP → source filter.
193+
pub mcast_subscriptions: HashMap<IpAddr, SourceFilter>,
175194
}
176195

177196
#[derive(Debug)]
@@ -237,7 +256,11 @@ impl Handle {
237256
return Err(OpteError::DuplicatePort(entry.key().to_string()));
238257
}
239258
Entry::Vacant(entry) => {
240-
entry.insert(PortData { port, routes: Vec::new() });
259+
entry.insert(PortData {
260+
port,
261+
routes: Vec::new(),
262+
mcast_subscriptions: HashMap::new(),
263+
});
241264
}
242265
}
243266
Ok(NO_RESPONSE)
@@ -270,14 +293,46 @@ impl Handle {
270293
Ok(NO_RESPONSE)
271294
}
272295

273-
/// Allow traffic to / from a CIDR block on a port.
274-
pub fn allow_cidr(
296+
/// Subscribe a port to a multicast group.
297+
pub fn mcast_subscribe(
275298
&self,
276-
_: &str,
277-
_: IpCidr,
278-
_: Direction,
299+
req: &McastSubscribeReq,
279300
) -> Result<NoResp, OpteError> {
280-
unimplemented!("Not yet used in tests")
301+
let mut inner = opte_state().lock().unwrap();
302+
let Some(port_data) = inner.ports.get_mut(&req.port_name) else {
303+
return Err(OpteError::NoPort(req.port_name.clone()));
304+
};
305+
let group_ip: IpAddr = match req.group {
306+
oxide_vpc::api::IpAddr::Ip4(v4) => {
307+
std::net::Ipv4Addr::from(v4).into()
308+
}
309+
oxide_vpc::api::IpAddr::Ip6(v6) => {
310+
std::net::Ipv6Addr::from(v6).into()
311+
}
312+
};
313+
port_data.mcast_subscriptions.insert(group_ip, req.filter.clone());
314+
Ok(NO_RESPONSE)
315+
}
316+
317+
/// Unsubscribe a port from a multicast group.
318+
pub fn mcast_unsubscribe(
319+
&self,
320+
req: &McastUnsubscribeReq,
321+
) -> Result<NoResp, OpteError> {
322+
let mut inner = opte_state().lock().unwrap();
323+
let Some(port_data) = inner.ports.get_mut(&req.port_name) else {
324+
return Err(OpteError::NoPort(req.port_name.clone()));
325+
};
326+
let group_ip: IpAddr = match req.group {
327+
oxide_vpc::api::IpAddr::Ip4(v4) => {
328+
std::net::Ipv4Addr::from(v4).into()
329+
}
330+
oxide_vpc::api::IpAddr::Ip6(v6) => {
331+
std::net::Ipv6Addr::from(v6).into()
332+
}
333+
};
334+
port_data.mcast_subscriptions.remove(&group_ip);
335+
Ok(NO_RESPONSE)
281336
}
282337

283338
/// Delete a router entry from a port.
@@ -323,6 +378,45 @@ impl Handle {
323378
unimplemented!("Not yet used in tests")
324379
}
325380

381+
/// Set a multicast-to-physical mapping.
382+
pub fn set_m2p(&self, _: &SetMcast2PhysReq) -> Result<NoResp, OpteError> {
383+
Ok(NO_RESPONSE)
384+
}
385+
386+
/// Clear a multicast-to-physical mapping.
387+
pub fn clear_m2p(
388+
&self,
389+
_: &ClearMcast2PhysReq,
390+
) -> Result<NoResp, OpteError> {
391+
Ok(NO_RESPONSE)
392+
}
393+
394+
/// Set multicast forwarding for a port.
395+
pub fn set_mcast_fwd(
396+
&self,
397+
_: &SetMcastForwardingReq,
398+
) -> Result<NoResp, OpteError> {
399+
Ok(NO_RESPONSE)
400+
}
401+
402+
/// Clear multicast forwarding for a port.
403+
pub fn clear_mcast_fwd(
404+
&self,
405+
_: &ClearMcastForwardingReq,
406+
) -> Result<NoResp, OpteError> {
407+
Ok(NO_RESPONSE)
408+
}
409+
410+
/// Dump all multicast-to-physical mappings.
411+
pub fn dump_m2p(&self) -> Result<DumpMcast2PhysResp, OpteError> {
412+
Ok(DumpMcast2PhysResp { ip4: Vec::new(), ip6: Vec::new() })
413+
}
414+
415+
/// Dump all multicast forwarding entries.
416+
pub fn dump_mcast_fwd(&self) -> Result<DumpMcastForwardingResp, OpteError> {
417+
Ok(DumpMcastForwardingResp { entries: Vec::new() })
418+
}
419+
326420
/// List ports on the current system.
327421
#[allow(dead_code)]
328422
pub(crate) fn list_ports(&self) -> Result<ListPortsResp, OpteError> {

0 commit comments

Comments
 (0)