From 21a39671861749184b34d4287b719c28df51f8ae Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Fri, 9 Oct 2026 16:35:24 -0300 Subject: [PATCH 01/10] big refactor --- Cargo.lock | 2 + aal/src/match_action.rs | 2 + asic/src/softnpu/table.rs | 58 +- dpd-api/Cargo.toml | 1 + dpd-api/src/lib.rs | 374 +++++++++-- .../integration_tests/attached_subnet.rs | 2 +- dpd-client/tests/integration_tests/common.rs | 14 +- dpd-client/tests/integration_tests/mod.rs | 1 + dpd-client/tests/integration_tests/nat.rs | 2 +- .../tests/integration_tests/route_ipv4.rs | 113 ++-- .../tests/integration_tests/route_ipv6.rs | 32 +- dpd-client/tests/integration_tests/router.rs | 527 ++++++++++++++++ .../tests/integration_tests/table_tests.rs | 14 +- dpd-types/versions/src/latest.rs | 11 +- dpd-types/versions/src/lib.rs | 2 + dpd-types/versions/src/multi_router/mod.rs | 13 + dpd-types/versions/src/multi_router/route.rs | 113 ++++ dpd/p4/metadata.p4 | 1 + dpd/p4/parser.p4 | 1 + dpd/p4/sidecar.p4 | 24 +- dpd/src/api_server.rs | 146 ++++- dpd/src/loopback.rs | 21 +- dpd/src/main.rs | 2 + dpd/src/route.rs | 595 ++++++++++++------ dpd/src/router.rs | 204 ++++++ dpd/src/table/port_ip.rs | 22 +- dpd/src/table/route_ipv4.rs | 25 +- dpd/src/table/route_ipv6.rs | 25 +- openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub | 1 + ...0.0-5db8bd.json => dpd-14.0.0-152d98.json} | 316 +++++++++- openapi/dpd/dpd-latest.json | 2 +- swadm/Cargo.toml | 1 + swadm/src/main.rs | 14 +- swadm/src/route.rs | 70 ++- swadm/src/router.rs | 77 +++ 35 files changed, 2439 insertions(+), 389 deletions(-) create mode 100644 dpd-client/tests/integration_tests/router.rs create mode 100644 dpd-types/versions/src/multi_router/mod.rs create mode 100644 dpd-types/versions/src/multi_router/route.rs create mode 100644 dpd/src/router.rs create mode 100644 openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub rename openapi/dpd/{dpd-13.0.0-5db8bd.json => dpd-14.0.0-152d98.json} (97%) create mode 100644 swadm/src/router.rs diff --git a/Cargo.lock b/Cargo.lock index 55b53c36..8fe032d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1726,6 +1726,7 @@ dependencies = [ "dpd-types-versions", "dropshot 0.17.1", "dropshot-api-manager-types", + "uuid", ] [[package]] @@ -7387,6 +7388,7 @@ dependencies = [ "tabwriter", "thiserror 2.0.18", "tokio", + "uuid", ] [[package]] diff --git a/aal/src/match_action.rs b/aal/src/match_action.rs index 5a776e15..3001463a 100644 --- a/aal/src/match_action.rs +++ b/aal/src/match_action.rs @@ -410,6 +410,8 @@ impl TryFrom<&ValueTypes> for u8 { } } +unwrap_value_entry!(u8); + impl From for ValueTypes { fn from(v: u16) -> ValueTypes { ValueTypes::U64(v as u64) diff --git a/asic/src/softnpu/table.rs b/asic/src/softnpu/table.rs index 56d9c1fe..44157b4e 100644 --- a/asic/src/softnpu/table.rs +++ b/asic/src/softnpu/table.rs @@ -121,6 +121,13 @@ impl TableOps for Table { // TODO: implement mappings for natv6 actions (TableType::PortAddrIpv4, "claimv4") => ("local", Vec::new()), (TableType::PortAddrIpv6, "claimv6") => ("local", Vec::new()), + (TableType::PortAddrIpv6, "claimv6_rid") => { + let table_id = u8::try_from( + &action_data.arg_by_name("routing_table_id")?.value, + ) + .map_err(|e| AsicError::InvalidArg(e.into()))?; + ("local_rid", vec![table_id]) + } (TableType::RouteIdxIpv4, "index") => { let mut params = Vec::new(); for arg in action_data.args.iter() { @@ -632,10 +639,10 @@ fn keyset_data(match_data: Vec, table: TableType) -> Vec { serialize_value_type(&x, &mut data); keyset_data.extend_from_slice(&data[..2]); } - TableType::RouteIdxIpv4 => { - // "idx" => exact => bit<16> + TableType::RouteIdxIpv4 | TableType::RouteIdxIpv6 => { + // "routing_table_id" => exact => bit<8> serialize_value_type(&x, &mut data); - keyset_data.extend_from_slice(&data[..2]); + keyset_data.push(data[0]); } TableType::NatIngressIpv4 => { // "dst_addr" => hdr.ipv4.dst: exact => bit<32> @@ -725,3 +732,48 @@ fn serialize_value_type_be(x: &ValueTypes, data: &mut Vec) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn route_key( + table_id: u8, + prefix: ValueTypes, + len: u16, + ) -> Vec { + vec![ + MatchEntryField { + name: "routing_table_id".into(), + value: MatchEntryValue::Value(table_id.into()), + }, + MatchEntryField { + name: "dst_addr".into(), + value: MatchEntryValue::Lpm(aal::MatchLpm { prefix, len }), + }, + ] + } + + #[test] + fn route_index_serializes_composite_key() { + assert_eq!( + keyset_data( + route_key(2, ValueTypes::U64(0xc000_0200), 24), + TableType::RouteIdxIpv4, + ), + [2, 192, 0, 2, 0, 24] + ); + + let prefix = 0x20010db8000000000000000000000000u128.to_be_bytes(); + let mut expected = vec![3]; + expected.extend_from_slice(&prefix); + expected.push(32); + assert_eq!( + keyset_data( + route_key(3, ValueTypes::Ptr(prefix.to_vec()), 32), + TableType::RouteIdxIpv6, + ), + expected + ); + } +} diff --git a/dpd-api/Cargo.toml b/dpd-api/Cargo.toml index 10730af0..20b17465 100644 --- a/dpd-api/Cargo.toml +++ b/dpd-api/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" dpd-types-versions.workspace = true dropshot.workspace = true dropshot-api-manager-types.workspace = true +uuid.workspace = true diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 7441178e..81c0f0cb 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -9,13 +9,15 @@ use std::collections::BTreeMap; use std::net::{Ipv4Addr, Ipv6Addr}; -use dpd_types_versions::{latest, v1, v4, v7}; +use dpd_types_versions::latest::route::DEFAULT_ROUTER; +use dpd_types_versions::{latest, v1, v4, v6, v7}; use dropshot::{ EmptyScanParams, HttpError, HttpResponseCreated, HttpResponseDeleted, HttpResponseOk, HttpResponseUpdatedNoContent, PaginationParams, Path, Query, RequestContext, ResultsPage, TypedBody, }; use dropshot_api_manager_types::api_versions; +use uuid::Uuid; api_versions!([ // WHEN CHANGING THE API (part 1 of 2): @@ -29,6 +31,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (14, MULTI_ROUTER), (13, ALLOW_DDM_TRAFFIC), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), @@ -181,6 +184,24 @@ pub trait DpdApi { path: Path, ) -> Result; + /** + * Fetch the IPv6 routes configured on the given router, mapping IPv6 + * CIDR blocks to the switch port used for sending out that traffic, and + * optionally a gateway. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv6_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + /** * Fetch the configured IPv6 routes, mapping IPv6 CIDR blocks to the switch port * used for sending out that traffic, and optionally a gateway. @@ -188,26 +209,67 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv6", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_list", }] - async fn route_ipv6_list( + async fn route_ipv6_list_v1( rqctx: RequestContext, query: Query< PaginationParams, >, - ) -> Result>, HttpError>; + ) -> Result>, HttpError> + { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv6_list(rqctx, path.into(), query).await + } /** - * Get a single IPv6 route, by its IPv6 CIDR block. + * Get a single IPv6 route on the given router, by its IPv6 CIDR block. */ #[endpoint { method = GET, - path = "/route/ipv6/{cidr}", + path = "/router/{router_id}/route/ipv6/{cidr}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv6_get( rqctx: RequestContext, path: Path, ) -> Result>, HttpError>; + /** + * Get a single IPv6 route, by its IPv6 CIDR block. + */ + #[endpoint { + method = GET, + path = "/route/ipv6/{cidr}", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_get", + }] + async fn route_ipv6_get_v1( + rqctx: RequestContext, + path: Path, + ) -> Result>, HttpError> { + Self::route_ipv6_get(rqctx, path.map(Into::into)).await + } + + /** + * Route an IPv6 subnet to a link and a nexthop gateway on the given + * router. + * + * This call can be used to create a new single-path route or to add new + * targets to a multipath route. + */ + #[endpoint { + method = POST, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv6_add( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + /** * Route an IPv6 subnet to a link and a nexthop gateway. * @@ -217,9 +279,32 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv6", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_add", }] - async fn route_ipv6_add( + async fn route_ipv6_add_v1( + rqctx: RequestContext, + update: TypedBody, + ) -> Result { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv6_add(rqctx, path.into(), update).await + } + + /** + * Route an IPv6 subnet to a link and a nexthop gateway on the given + * router. + * + * This call can be used to create a new single-path route or to replace + * any existing routes with a new single-path route. + */ + #[endpoint { + method = PUT, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv6_set( rqctx: RequestContext, + path: Path, update: TypedBody, ) -> Result; @@ -232,18 +317,24 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv6", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_set", }] - async fn route_ipv6_set( + async fn route_ipv6_set_v1( rqctx: RequestContext, update: TypedBody, - ) -> Result; + ) -> Result { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv6_set(rqctx, path.into(), update).await + } /** - * Remove an IPv6 route, by its IPv6 CIDR block. + * Remove an IPv6 route on the given router, by its IPv6 CIDR block. */ #[endpoint { method = DELETE, - path = "/route/ipv6/{cidr}", + path = "/router/{router_id}/route/ipv6/{cidr}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv6_delete( rqctx: RequestContext, @@ -251,17 +342,68 @@ pub trait DpdApi { ) -> Result; /** - * Remove a single target for the given IPv6 subnet + * Remove an IPv6 route, by its IPv6 CIDR block. */ #[endpoint { method = DELETE, - path = "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", + path = "/route/ipv6/{cidr}", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_delete", + }] + async fn route_ipv6_delete_v1( + rqctx: RequestContext, + path: Path, + ) -> Result { + Self::route_ipv6_delete(rqctx, path.map(Into::into)).await + } + + /** + * Remove a single target for the given IPv6 subnet on the given router. + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv6_delete_target( rqctx: RequestContext, path: Path, ) -> Result; + /** + * Remove a single target for the given IPv6 subnet + */ + #[endpoint { + method = DELETE, + path = "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv6_delete_target", + }] + async fn route_ipv6_delete_target_v1( + rqctx: RequestContext, + path: Path, + ) -> Result { + Self::route_ipv6_delete_target(rqctx, path.map(Into::into)).await + } + + /** + * Fetch the IPv4 routes configured on the given router, mapping IPv4 + * CIDR blocks to the switch port used for sending out that traffic, and + * optionally a gateway. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv4_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + /** * Fetch the configured IPv4 routes, mapping IPv4 CIDR blocks to the switch port * used for sending out that traffic, and optionally a gateway. @@ -269,14 +411,19 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv4", - versions = VERSION_V4_OVER_V6_ROUTES.. + versions = VERSION_V4_OVER_V6_ROUTES..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_list", }] - async fn route_ipv4_list( + async fn route_ipv4_list_v4( rqctx: RequestContext, query: Query< PaginationParams, >, - ) -> Result>, HttpError>; + ) -> Result>, HttpError> + { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_list(rqctx, path.into(), query).await + } /** * Fetch the configured IPv4 routes, mapping IPv4 CIDR blocks to the switch port @@ -294,7 +441,8 @@ pub trait DpdApi { >, ) -> Result>, HttpError> { - let page = Self::route_ipv4_list(rqctx, query).await?.0; + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + let page = Self::route_ipv4_list(rqctx, path.into(), query).await?.0; Ok(HttpResponseOk(ResultsPage { next_page: page.next_page, items: page.items.into_iter().map(Into::into).collect(), @@ -302,18 +450,34 @@ pub trait DpdApi { } /** - * Get the configured route for the given IPv4 subnet. + * Get the configured route for the given IPv4 subnet on the given router. */ #[endpoint { method = GET, - path = "/route/ipv4/{cidr}", - versions = VERSION_V4_OVER_V6_ROUTES.. + path = "/router/{router_id}/route/ipv4/{cidr}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv4_get( rqctx: RequestContext, path: Path, ) -> Result>, HttpError>; + /** + * Get the configured route for the given IPv4 subnet. + */ + #[endpoint { + method = GET, + path = "/route/ipv4/{cidr}", + versions = VERSION_V4_OVER_V6_ROUTES..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_get", + }] + async fn route_ipv4_get_v4( + rqctx: RequestContext, + path: Path, + ) -> Result>, HttpError> { + Self::route_ipv4_get(rqctx, path.map(Into::into)).await + } + #[endpoint { method = GET, path = "/route/ipv4/{cidr}", @@ -323,7 +487,7 @@ pub trait DpdApi { rqctx: RequestContext, path: Path, ) -> Result>, HttpError> { - let result = Self::route_ipv4_get(rqctx, path).await?.0; + let result = Self::route_ipv4_get(rqctx, path.map(Into::into)).await?.0; Ok(HttpResponseOk( result .into_iter() @@ -335,6 +499,24 @@ pub trait DpdApi { )) } + /** + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the given router. + * + * This call can be used to create a new single-path route or to add new + * targets to a multipath route. + */ + #[endpoint { + method = POST, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv4_add( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + /** * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6). * @@ -344,12 +526,16 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES.. + versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_add", }] - async fn route_ipv4_add( + async fn route_ipv4_add_v6( rqctx: RequestContext, update: TypedBody, - ) -> Result; + ) -> Result { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_add(rqctx, path.into(), update).await + } /** * Route an IPv4 subnet to a link and an IPv6 nexthop gateway. @@ -367,7 +553,8 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - Self::route_ipv4_add(rqctx, update.map(Into::into)).await + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_add(rqctx, path.into(), update.map(Into::into)).await } /** @@ -386,9 +573,28 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - Self::route_ipv4_add(rqctx, update.map(Into::into)).await + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_add(rqctx, path.into(), update.map(Into::into)).await } + /** + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the given router. + * + * This call can be used to create a new single-path route or to replace + * any existing routes with a new single-path route. + */ + #[endpoint { + method = PUT, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MULTI_ROUTER.. + }] + async fn route_ipv4_set( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + /** * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6). * @@ -398,12 +604,16 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES.. + versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_set", }] - async fn route_ipv4_set( + async fn route_ipv4_set_v6( rqctx: RequestContext, update: TypedBody, - ) -> Result; + ) -> Result { + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_set(rqctx, path.into(), update).await + } /** * Route an IPv4 subnet to a link and an IPv6 nexthop gateway. @@ -421,7 +631,8 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - Self::route_ipv4_set(rqctx, update.map(Into::into)).await + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_set(rqctx, path.into(), update.map(Into::into)).await } /** @@ -440,15 +651,17 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - Self::route_ipv4_set(rqctx, update.map(Into::into)).await + let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; + Self::route_ipv4_set(rqctx, path.into(), update.map(Into::into)).await } /** - * Remove all targets for the given subnet + * Remove all targets for the given subnet on the given router. */ #[endpoint { method = DELETE, - path = "/route/ipv4/{cidr}", + path = "/router/{router_id}/route/ipv4/{cidr}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv4_delete( rqctx: RequestContext, @@ -456,18 +669,51 @@ pub trait DpdApi { ) -> Result; /** - * Remove a single target for the given IPv4 subnet (IPv4 or IPv6 next hop) + * Remove all targets for the given subnet */ #[endpoint { method = DELETE, - path = "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_CONSOLIDATED_V4_ROUTES.. + path = "/route/ipv4/{cidr}", + versions = ..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_delete", + }] + async fn route_ipv4_delete_v1( + rqctx: RequestContext, + path: Path, + ) -> Result { + Self::route_ipv4_delete(rqctx, path.map(Into::into)).await + } + + /** + * Remove a single target for the given IPv4 subnet on the given router + * (IPv4 or IPv6 next hop). + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = VERSION_MULTI_ROUTER.. }] async fn route_ipv4_delete_target( rqctx: RequestContext, path: Path, ) -> Result; + /** + * Remove a single target for the given IPv4 subnet (IPv4 or IPv6 next hop) + */ + #[endpoint { + method = DELETE, + path = "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + operation_id = "route_ipv4_delete_target", + }] + async fn route_ipv4_delete_target_v6( + rqctx: RequestContext, + path: Path, + ) -> Result { + Self::route_ipv4_delete_target(rqctx, path.map(Into::into)).await + } + /** * Remove a single target for the given IPv4 subnet */ @@ -481,9 +727,65 @@ pub trait DpdApi { rqctx: RequestContext, path: Path, ) -> Result { - Self::route_ipv4_delete_target(rqctx, path.map(Into::into)).await + let path = path.map(|p| v6::route::RouteTargetIpv4Path::from(p).into()); + Self::route_ipv4_delete_target(rqctx, path).await } + /** + * List the routers with their endpoints. + */ + #[endpoint { + method = GET, + path = "/router", + versions = VERSION_MULTI_ROUTER.. + }] + async fn router_list( + rqctx: RequestContext, + ) -> Result>, HttpError>; + + /** + * Get a router's endpoint. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}", + versions = VERSION_MULTI_ROUTER.. + }] + async fn router_get( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + /** + * Create a router with the given endpoint. An address can be the + * endpoint of only one router, and a router's endpoint can't be changed. + * The default router's uuid names table 0 and can't be used. + */ + #[endpoint { + method = PUT, + path = "/router/{router_id}", + versions = VERSION_MULTI_ROUTER.. + }] + async fn router_create( + rqctx: RequestContext, + path: Path, + endpoint: TypedBody, + ) -> Result; + + /** + * Delete a router, along with its endpoint and routes. The default + * router's uuid names table 0 and can't be used. + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}", + versions = VERSION_MULTI_ROUTER.. + }] + async fn router_delete( + rqctx: RequestContext, + path: Path, + ) -> Result; + /// List all switch ports on the system. #[endpoint { method = GET, diff --git a/dpd-client/tests/integration_tests/attached_subnet.rs b/dpd-client/tests/integration_tests/attached_subnet.rs index 19f7ff96..97d95fd0 100644 --- a/dpd-client/tests/integration_tests/attached_subnet.rs +++ b/dpd-client/tests/integration_tests/attached_subnet.rs @@ -226,7 +226,7 @@ async fn test_ingress(switch: &Switch, test: &ExternalTest) -> TestResult { // Add the route to the gimlet over the backplane, and populate the neighbor // table with its mac address. - switch.client.route_ipv6_set(&route).await.unwrap(); + switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await.unwrap(); common::add_neighbor_ipv6(switch, &test.gimlet_ip, gimlet_mac).await?; // Set up an external subnet owned by an instance on the test gimlet diff --git a/dpd-client/tests/integration_tests/common.rs b/dpd-client/tests/integration_tests/common.rs index 3d50f82a..a18f166b 100644 --- a/dpd-client/tests/integration_tests/common.rs +++ b/dpd-client/tests/integration_tests/common.rs @@ -23,6 +23,7 @@ use dpd_client::Client; use dpd_client::ClientInfo; use dpd_client::ClientState; use dpd_client::types; +pub use dpd_types::route::DEFAULT_ROUTER; use packet::Endpoint; use packet::Packet; use packet::arp; @@ -1107,13 +1108,13 @@ async fn set_route_ipv6_common( }; switch .client - .route_ipv6_set(&route) + .route_ipv6_set(&DEFAULT_ROUTER, &route) .await .expect("Failed to add IPv6 route entry"); let route = switch .client - .route_ipv6_get(&cidr) + .route_ipv6_get(&DEFAULT_ROUTER, &cidr) .await .expect("Failed to get just-added IPv6 route entry") .into_inner(); @@ -1201,13 +1202,13 @@ async fn set_route_ipv4_common( }; switch .client - .route_ipv4_set(&route) + .route_ipv4_set(&DEFAULT_ROUTER, &route) .await .expect("Failed to add IPv4 route entry"); let route = switch .client - .route_ipv4_get(&cidr) + .route_ipv4_get(&DEFAULT_ROUTER, &cidr) .await .expect("failed to get just-added IPv4 route entry") .into_inner(); @@ -1256,13 +1257,13 @@ async fn set_route_ipv4_over_ipv6_common( }; switch .client - .route_ipv4_set(&route) + .route_ipv4_set(&DEFAULT_ROUTER, &route) .await .expect("Failed to add IPv4 route entry"); let route = switch .client - .route_ipv4_get(&cidr) + .route_ipv4_get(&DEFAULT_ROUTER, &cidr) .await .expect("failed to get just-added IPv4 route entry") .into_inner(); @@ -1443,6 +1444,7 @@ pub fn gen_arp_reply(src: Endpoint, tgt: Endpoint) -> Packet { pub mod prelude { pub use super::ADMIN_LOCAL_MULTICAST_PREFIX; + pub use super::DEFAULT_ROUTER; pub use super::NO_PORT; pub use super::PhysPort; pub use super::SERVICE_PORT; diff --git a/dpd-client/tests/integration_tests/mod.rs b/dpd-client/tests/integration_tests/mod.rs index 29c5f2e6..4899654f 100644 --- a/dpd-client/tests/integration_tests/mod.rs +++ b/dpd-client/tests/integration_tests/mod.rs @@ -16,5 +16,6 @@ mod nat; mod port_api; mod route_ipv4; mod route_ipv6; +mod router; mod service; mod table_tests; diff --git a/dpd-client/tests/integration_tests/nat.rs b/dpd-client/tests/integration_tests/nat.rs index 9d7372a1..a64859bc 100644 --- a/dpd-client/tests/integration_tests/nat.rs +++ b/dpd-client/tests/integration_tests/nat.rs @@ -338,7 +338,7 @@ async fn test_nat_ingress(switch: &Switch, test: &NatTest) -> TestResult { }, replace: false, }; - switch.client.route_ipv6_set(&route).await.unwrap(); + switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await.unwrap(); common::add_neighbor_ipv6(switch, &test.gimlet_ip, gimlet_mac).await?; let load = vec![0xaau8, 0xbb, 0xcc, 0xdd, 0xee]; diff --git a/dpd-client/tests/integration_tests/route_ipv4.rs b/dpd-client/tests/integration_tests/route_ipv4.rs index 37cd8fcc..fd13856e 100644 --- a/dpd-client/tests/integration_tests/route_ipv4.rs +++ b/dpd-client/tests/integration_tests/route_ipv4.rs @@ -65,7 +65,7 @@ async fn add_route( let route = router.build_route(switch); let route_add = build_route_update(cidr, &route, false); - client.route_ipv4_add(&route_add).await?; + client.route_ipv4_add(&DEFAULT_ROUTER, &route_add).await?; Ok(()) } @@ -88,7 +88,7 @@ async fn validate_routes( expected: &[types::Ipv4Route], ) -> TestResult { if expected.is_empty() { - match client.route_ipv4_get(&cidr).await { + match client.route_ipv4_get(&DEFAULT_ROUTER, cidr).await { Ok(f) => { Err(anyhow!("found {} targets - expected no route", f.len())) } @@ -96,7 +96,7 @@ async fn validate_routes( } } else { // Verify that the set of routes on the switch match those we just set - let found = client.route_ipv4_get(&cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, cidr).await?; assert_eq!(found.len(), expected.len()); for target in expected { assert!( @@ -146,7 +146,7 @@ async fn test_deleted_route_ipv4_impl(switch: &Switch) -> TestResult { // Delete that route entry, and check that we cannot send out the egress // port. let cidr = "10.10.10.0/24".parse().unwrap(); - switch.client.route_ipv4_delete(&cidr).await.unwrap(); + switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await.unwrap(); let to_send = common::gen_udp_packet( Endpoint::parse("e0:d5:5e:67:89:ab", "10.10.10.10", 3333).unwrap(), @@ -348,7 +348,7 @@ async fn test_reset() -> TestResult { let limit = std::num::NonZeroU32::new(32).unwrap(); let routes = switch .client - .route_ipv4_list(Some(limit), None) + .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -357,7 +357,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("failed").await.unwrap(); let routes = switch .client - .route_ipv4_list(Some(limit), None) + .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -366,7 +366,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("test").await.unwrap(); let routes = switch .client - .route_ipv4_list(Some(limit), None) + .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -390,15 +390,18 @@ async fn test_create_and_set_semantics_v4() -> TestResult { let route_set_33 = build_route_update(cidr, &route_33, false); // Setting a new route should work - client.route_ipv4_set(&route_set_47).await?; + client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_47).await?; // Attempting to replace the route with "replace = false" should fail - client.route_ipv4_set(&route_set_33).await.expect_err("expected conflict"); + client + .route_ipv4_set(&DEFAULT_ROUTER, &route_set_33) + .await + .expect_err("expected conflict"); // Re-setting the existing route should succeed - client.route_ipv4_set(&route_set_47).await?; + client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_47).await?; // Attempting to replace the route with "replace = true" should succeed let route_set_33 = build_route_update(cidr, &route_33, true); - client.route_ipv4_set(&route_set_33).await?; + client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_33).await?; // Verify that the route was replaced correctly validate_routes(client, &cidr, &[route_33]).await } @@ -464,6 +467,7 @@ async fn delete_ipv4_route_target( let tgt_ip: std::net::IpAddr = target.tgt_ip.into(); client .route_ipv4_delete_target( + &DEFAULT_ROUTER, cidr, &target.port_id, &target.link_id, @@ -517,7 +521,12 @@ async fn add_ipv4_route_target( cidr: Ipv4Net, route: &types::Ipv4Route, ) -> Result<(), dpd_client::Error> { - client.route_ipv4_add(&build_route_update(cidr, route, false)).await?; + client + .route_ipv4_add( + &DEFAULT_ROUTER, + &build_route_update(cidr, route, false), + ) + .await?; Ok(()) } @@ -527,11 +536,14 @@ async fn add_ipv4_over_ipv6_route_target( route: &types::Ipv6Route, ) -> Result<(), dpd_client::Error> { client - .route_ipv4_add(&types::Ipv4RouteUpdate { - cidr, - target: types::RouteTarget::V6(route.clone()), - replace: false, - }) + .route_ipv4_add( + &DEFAULT_ROUTER, + &types::Ipv4RouteUpdate { + cidr, + target: types::RouteTarget::V6(route.clone()), + replace: false, + }, + ) .await?; Ok(()) } @@ -764,7 +776,7 @@ async fn test_add_target_succeeds_when_table_fragmented_v4_over_v6() add_ipv4_over_ipv6_route_target(client, victim_cidr, &new_target).await?; victim_routes.push(new_target); - let found = client.route_ipv4_get(&victim_cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &victim_cidr).await?; assert_eq!(found.len(), victim_routes.len()); for route in &victim_routes { assert!(found.contains(&types::Route::V6(route.clone()))); @@ -930,11 +942,17 @@ async fn test_v4_over_v6() -> TestResult { let tgt_ip: std::net::IpAddr = gw.into(); switch .client - .route_ipv4_delete_target(&cidr, &port_id, &link_id, &tgt_ip) + .route_ipv4_delete_target( + &DEFAULT_ROUTER, + &cidr, + &port_id, + &link_id, + &tgt_ip, + ) .await?; switch .client - .route_ipv4_get(&cidr) + .route_ipv4_get(&DEFAULT_ROUTER, &cidr) .await .expect_err("route should be gone after deleting its only target"); @@ -967,10 +985,10 @@ async fn test_v4_over_v6() -> TestResult { switch.packet_test(vec![send], vec![expected])?; // Delete the entire prefix and verify it is gone. - switch.client.route_ipv4_delete(&cidr).await?; + switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; switch .client - .route_ipv4_get(&cidr) + .route_ipv4_get(&DEFAULT_ROUTER, &cidr) .await .expect_err("route should be gone after deleting the prefix"); @@ -1039,16 +1057,15 @@ async fn test_multipath_mixed_delete() -> TestResult { types::RouteTarget::V6(v6_d.clone()), ] { client - .route_ipv4_add(&types::Ipv4RouteUpdate { - cidr, - target, - replace: false, - }) + .route_ipv4_add( + &DEFAULT_ROUTER, + &types::Ipv4RouteUpdate { cidr, target, replace: false }, + ) .await?; } // Verify all four targets are present. - let found = client.route_ipv4_get(&cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; assert_eq!(found.len(), 4); assert!(found.contains(&types::Route::V4(v4_a.clone()))); assert!(found.contains(&types::Route::V6(v6_b.clone()))); @@ -1058,9 +1075,15 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v6 target B — the two v4 targets and v6 target D should remain. let tgt_ip: IpAddr = v6_b.tgt_ip.into(); client - .route_ipv4_delete_target(&cidr, &v6_b.port_id, &v6_b.link_id, &tgt_ip) + .route_ipv4_delete_target( + &DEFAULT_ROUTER, + &cidr, + &v6_b.port_id, + &v6_b.link_id, + &tgt_ip, + ) .await?; - let found = client.route_ipv4_get(&cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; assert_eq!(found.len(), 3); assert!(found.contains(&types::Route::V4(v4_a.clone()))); assert!(found.contains(&types::Route::V4(v4_c.clone()))); @@ -1069,9 +1092,15 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v4 target A — v4 target C and v6 target D should remain. let tgt_ip: IpAddr = v4_a.tgt_ip.into(); client - .route_ipv4_delete_target(&cidr, &v4_a.port_id, &v4_a.link_id, &tgt_ip) + .route_ipv4_delete_target( + &DEFAULT_ROUTER, + &cidr, + &v4_a.port_id, + &v4_a.link_id, + &tgt_ip, + ) .await?; - let found = client.route_ipv4_get(&cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; assert_eq!(found.len(), 2); assert!(found.contains(&types::Route::V4(v4_c.clone()))); assert!(found.contains(&types::Route::V6(v6_d.clone()))); @@ -1079,19 +1108,31 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v6 target D — only v4 target C should remain. let tgt_ip: IpAddr = v6_d.tgt_ip.into(); client - .route_ipv4_delete_target(&cidr, &v6_d.port_id, &v6_d.link_id, &tgt_ip) + .route_ipv4_delete_target( + &DEFAULT_ROUTER, + &cidr, + &v6_d.port_id, + &v6_d.link_id, + &tgt_ip, + ) .await?; - let found = client.route_ipv4_get(&cidr).await?; + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; assert_eq!(found.len(), 1); assert!(found.contains(&types::Route::V4(v4_c.clone()))); // Delete the last v4 target C — the route should be completely gone. let tgt_ip: IpAddr = v4_c.tgt_ip.into(); client - .route_ipv4_delete_target(&cidr, &v4_c.port_id, &v4_c.link_id, &tgt_ip) + .route_ipv4_delete_target( + &DEFAULT_ROUTER, + &cidr, + &v4_c.port_id, + &v4_c.link_id, + &tgt_ip, + ) .await?; client - .route_ipv4_get(&cidr) + .route_ipv4_get(&DEFAULT_ROUTER, &cidr) .await .expect_err("route should be gone after deleting all targets"); diff --git a/dpd-client/tests/integration_tests/route_ipv6.rs b/dpd-client/tests/integration_tests/route_ipv6.rs index dbdf6917..dfe74605 100644 --- a/dpd-client/tests/integration_tests/route_ipv6.rs +++ b/dpd-client/tests/integration_tests/route_ipv6.rs @@ -67,7 +67,7 @@ async fn add_route( let route = router.build_route(switch); let route_add = build_route_add(cidr, &route); - client.route_ipv6_add(&route_add).await?; + client.route_ipv6_add(&DEFAULT_ROUTER, &route_add).await?; Ok(()) } @@ -78,14 +78,14 @@ async fn validate_routes( expected: &[types::Ipv6Route], ) -> TestResult { if expected.is_empty() { - match client.route_ipv6_get(cidr).await { + match client.route_ipv6_get(&DEFAULT_ROUTER, cidr).await { Ok(f) => { Err(anyhow!("found {} targets - expected no route", f.len())) } Err(_) => Ok(()), } } else { - let found = client.route_ipv6_get(cidr).await?; + let found = client.route_ipv6_get(&DEFAULT_ROUTER, cidr).await?; assert_eq!(found.len(), expected.len()); for target in expected { assert!(found.iter().any(|t| t == target)); @@ -187,7 +187,7 @@ async fn test_deleted_unicast_impl(switch: &Switch) -> TestResult { test_unicast_impl(switch, None).await?; let cidr = "fd00:1122:3344:0100::/56".parse().unwrap(); - switch.client.route_ipv6_delete(&cidr).await.unwrap(); + switch.client.route_ipv6_delete(&DEFAULT_ROUTER, &cidr).await.unwrap(); let to_send = common::gen_udp_packet( Endpoint::parse("e0:d5:5e:67:89:ab", "fd00:1122:7788:0101::4", 3333) @@ -562,7 +562,7 @@ async fn test_reset() -> TestResult { let limit = std::num::NonZeroU32::new(32).unwrap(); let routes = switch .client - .route_ipv6_list(Some(limit), None) + .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -571,7 +571,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("failed").await.unwrap(); let routes = switch .client - .route_ipv6_list(Some(limit), None) + .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -580,7 +580,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("test").await.unwrap(); let routes = switch .client - .route_ipv6_list(Some(limit), None) + .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) .await .unwrap() .into_inner(); @@ -617,17 +617,20 @@ async fn test_create_and_set_semantics_v6() -> TestResult { }; // Setting a new route should work - client.route_ipv6_set(&route47).await?; + client.route_ipv6_set(&DEFAULT_ROUTER, &route47).await?; // Attempting to replace the route with "replace = false" should fail - client.route_ipv6_set(&route33).await.expect_err("expected conflict"); + client + .route_ipv6_set(&DEFAULT_ROUTER, &route33) + .await + .expect_err("expected conflict"); // Re-setting the existing route should succeed - client.route_ipv6_set(&route47).await?; + client.route_ipv6_set(&DEFAULT_ROUTER, &route47).await?; // Attempting to replace the route with "replace = true" should success route33.replace = true; - client.route_ipv6_set(&route33).await?; + client.route_ipv6_set(&DEFAULT_ROUTER, &route33).await?; // Verify that the route was replaced correctly - let rt = client.route_ipv6_get(&cidr).await?; + let rt = client.route_ipv6_get(&DEFAULT_ROUTER, &cidr).await?; assert_eq!(rt.len(), 1); assert_eq!(rt[0].tgt_ip, target33.tgt_ip); @@ -670,7 +673,9 @@ async fn add_ipv6_route_target( cidr: Ipv6Net, route: &types::Ipv6Route, ) -> Result<(), dpd_client::Error> { - client.route_ipv6_add(&build_route_add(cidr, route)).await?; + client + .route_ipv6_add(&DEFAULT_ROUTER, &build_route_add(cidr, route)) + .await?; Ok(()) } @@ -681,6 +686,7 @@ async fn delete_ipv6_route_target( ) -> TestResult { client .route_ipv6_delete_target( + &DEFAULT_ROUTER, cidr, &target.port_id, &target.link_id, diff --git a/dpd-client/tests/integration_tests/router.rs b/dpd-client/tests/integration_tests/router.rs new file mode 100644 index 00000000..46664c74 --- /dev/null +++ b/dpd-client/tests/integration_tests/router.rs @@ -0,0 +1,527 @@ +// 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::collections::HashMap; +use std::net::Ipv6Addr; +use std::sync::Arc; + +use dpd_client::ClientInfo; +use dpd_client::types; +use oxnet::Ipv4Net; +use packet::Endpoint; +use packet::Packet; +use packet::eth; +use packet::geneve; +use reqwest::StatusCode; +use uuid::Uuid; + +use crate::integration_tests::common; +use crate::integration_tests::common::prelude::*; + +fn ipv4_route(switch: &Switch, port: u16, gw: &str) -> types::Ipv4Route { + let (port_id, link_id) = switch.link_id(PhysPort(port)).unwrap(); + types::Ipv4Route { + port_id, + link_id, + tgt_ip: gw.parse().unwrap(), + tag: "testing".into(), + vlan_id: None, + } +} + +fn route_update( + cidr: Ipv4Net, + target: &types::Ipv4Route, +) -> types::Ipv4RouteUpdate { + types::Ipv4RouteUpdate { + cidr, + target: types::RouteTarget::V4(target.clone()), + replace: false, + } +} + +fn ipv6(addr: &str) -> Ipv6Addr { + addr.parse().unwrap() +} + +/// The same prefix can be routed differently by different routers. +#[tokio::test] +#[ignore] +async fn test_same_prefix_two_routers() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let cidr: Ipv4Net = "10.201.0.0/24".parse().unwrap(); + let default_target = ipv4_route(switch, 10, "10.10.10.1"); + let router_target = ipv4_route(switch, 14, "10.10.14.1"); + + client.router_create(&router, &ipv6("fd00:201::1")).await?; + client + .route_ipv4_add(&DEFAULT_ROUTER, &route_update(cidr, &default_target)) + .await?; + client.route_ipv4_add(&router, &route_update(cidr, &router_target)).await?; + + let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + assert_eq!(*found, vec![types::Route::V4(default_target)]); + let found = client.route_ipv4_get(&router, &cidr).await?; + assert_eq!(*found, vec![types::Route::V4(router_target)]); + + client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.router_delete(&router).await?; + Ok(()) +} + +/// Deleting a router removes its routes and endpoint and doesn't affect +/// another router. +#[tokio::test] +#[ignore] +async fn test_router_delete() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let cidr: Ipv4Net = "10.202.0.0/24".parse().unwrap(); + let target = ipv4_route(switch, 10, "10.10.10.1"); + let endpoint = ipv6("fd00:202::2"); + + client.router_create(&router, &endpoint).await?; + for r in [DEFAULT_ROUTER, router] { + client.route_ipv4_add(&r, &route_update(cidr, &target)).await?; + } + + client.router_delete(&router).await?; + + assert!(!client.router_list().await?.contains_key(&router.to_string())); + let err = client.route_ipv4_get(&router, &cidr).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); + assert_eq!(client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?.len(), 1); + + // The deleted router's endpoint is free to be claimed again. + let again = Uuid::new_v4(); + client.router_create(&again, &endpoint).await?; + + client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.router_delete(&again).await?; + Ok(()) +} + +/// Operations naming a router that was never created fail with 404, except +/// delete. +#[tokio::test] +#[ignore] +async fn test_uncreated_router() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let cidr: Ipv4Net = "10.203.0.0/24".parse().unwrap(); + let target = ipv4_route(switch, 10, "10.10.10.1"); + + let statuses = [ + client.route_ipv4_list(&router, None, None).await.map(|_| ()), + client.route_ipv4_get(&router, &cidr).await.map(|_| ()), + client + .route_ipv4_add(&router, &route_update(cidr, &target)) + .await + .map(|_| ()), + client.route_ipv4_delete(&router, &cidr).await.map(|_| ()), + client.route_ipv6_list(&router, None, None).await.map(|_| ()), + client.router_get(&router).await.map(|_| ()), + ] + .map(|r| r.unwrap_err().status()); + for status in statuses { + assert_eq!(status, Some(StatusCode::NOT_FOUND)); + } + let resp = client.router_delete(&router).await?; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +/// An address can be the endpoint of only one router, and an endpoint can't +/// take a loopback's address. +#[tokio::test] +#[ignore] +async fn test_endpoint_unique() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + let endpoint = ipv6("fd00:204::1"); + let lo = types::Ipv6Entry { + tag: "testing".into(), + addr: "fd00:204::9".parse().unwrap(), + }; + + client.router_create(&a, &endpoint).await?; + let err = client.router_create(&b, &endpoint).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + client.loopback_ipv6_create(&lo).await?; + let err = client.router_create(&b, &lo.addr).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + + // None of the refused requests left anything behind. + assert_eq!( + client.router_list().await?.into_inner(), + HashMap::from([(a.to_string(), endpoint)]) + ); + + client.loopback_ipv6_delete(&lo.addr).await?; + client.router_delete(&a).await?; + Ok(()) +} + +/// A router's endpoint is listed as a loopback, tagged with the router's +/// uuid. It can't be added or deleted through the loopback API. +#[tokio::test] +#[ignore] +async fn test_endpoint_in_loopback_list() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let endpoint = ipv6("fd00:20a::1"); + let tag_of = |loopbacks: &[types::Ipv6Entry]| { + loopbacks.iter().find(|e| e.addr == endpoint).map(|e| e.tag.clone()) + }; + + client.router_create(&router, &endpoint).await?; + let tag = tag_of(&client.loopback_ipv6_list().await?); + assert_eq!(tag, Some(router.to_string())); + + let err = client.loopback_ipv6_delete(&endpoint).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + + let lo = types::Ipv6Entry { tag: "testing".into(), addr: endpoint }; + let err = client.loopback_ipv6_create(&lo).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + let tag = tag_of(&client.loopback_ipv6_list().await?); + assert_eq!(tag, Some(router.to_string())); + + client.router_delete(&router).await?; + assert_eq!(tag_of(&client.loopback_ipv6_list().await?), None); + Ok(()) +} + +/// Creating a router again with the same endpoint succeeds, and with a +/// different endpoint fails. +#[tokio::test] +#[ignore] +async fn test_router_create_delete() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let endpoint = ipv6("fd00:205::1"); + + for _ in 0..2 { + let resp = client.router_create(&router, &endpoint).await?; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + } + let err = + client.router_create(&router, &ipv6("fd00:205::2")).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + assert_eq!(*client.router_get(&router).await?, endpoint); + assert!(client.router_list().await?.contains_key(&router.to_string())); + + let resp = client.router_delete(&router).await?; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let err = client.router_get(&router).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); + Ok(()) +} + +/// The default router can't be created, deleted or fetched, but its routes +/// work normally. +#[tokio::test] +#[ignore] +async fn test_default_router_is_not_a_router() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let cidr: Ipv4Net = "10.206.0.0/24".parse().unwrap(); + let target = ipv4_route(switch, 10, "10.10.10.1"); + + client + .route_ipv4_add(&DEFAULT_ROUTER, &route_update(cidr, &target)) + .await?; + + let err = client.router_get(&DEFAULT_ROUTER).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); + let err = client + .router_create(&DEFAULT_ROUTER, &ipv6("fd00:206::1")) + .await + .unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); + let err = client.router_delete(&DEFAULT_ROUTER).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); + assert!(client.router_list().await?.is_empty()); + assert_eq!(client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?.len(), 1); + + client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + Ok(()) +} + +/// A route added by an API v13 client uses the default router. +#[tokio::test] +#[ignore] +async fn test_legacy_route_uses_default_router() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let cidr: Ipv4Net = "10.205.0.0/24".parse().unwrap(); + let target = ipv4_route(switch, 10, "10.10.10.1"); + + let resp = reqwest::Client::new() + .post(format!("{}/route/ipv4", client.baseurl())) + .header("api-version", "13.0.0") + .json(&route_update(cidr, &target)) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let routes = client.route_ipv4_list(&DEFAULT_ROUTER, None, None).await?; + let found = routes.items.iter().find(|r| r.cidr == cidr).unwrap(); + assert_eq!(found.targets, vec![types::Route::V4(target)]); + + client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + Ok(()) +} + +/// An IPv6 loopback added by an API v13 client lands in the loopback list. +#[tokio::test] +#[ignore] +async fn test_legacy_loopback() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let lo = types::Ipv6Entry { + tag: "testing".into(), + addr: "fd00:207::1".parse().unwrap(), + }; + + let resp = reqwest::Client::new() + .post(format!("{}/loopback/ipv6", client.baseurl())) + .header("api-version", "13.0.0") + .json(&lo) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let loopbacks = client.loopback_ipv6_list().await?; + assert!(loopbacks.iter().any(|entry| entry.addr == lo.addr)); + assert!(client.router_list().await?.is_empty()); + + client.loopback_ipv6_delete(&lo.addr).await?; + Ok(()) +} + +/// A switch port's link address can't be any router's endpoint. +#[tokio::test] +#[ignore] +async fn test_link_address_is_not_endpoint() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + add_link_addr(switch).await?; + + let link_addr = ipv6(LINK_ADDR); + let err = + client.router_create(&Uuid::new_v4(), &link_addr).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::CONFLICT)); + assert!(client.router_list().await?.is_empty()); + Ok(()) +} + +/// Resetting the switch deletes the created routers, and their endpoints can +/// be claimed again. +#[tokio::test] +#[ignore] +async fn test_reset_removes_routers() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let router = Uuid::new_v4(); + let endpoint = ipv6("fd00:208::1"); + + client.router_create(&router, &endpoint).await?; + + client.reset_all().await?; + + assert!(client.router_list().await?.is_empty()); + let again = Uuid::new_v4(); + client.router_create(&again, &endpoint).await?; + + client.router_delete(&again).await?; + Ok(()) +} + +/// A router created after another was deleted reuses its routing table, but +/// none of its routes. +#[tokio::test] +#[ignore] +async fn test_recreated_router_has_no_routes() -> TestResult { + let switch = &*get_switch().await; + let client = &switch.client; + let cidr: Ipv4Net = "10.209.0.0/24".parse().unwrap(); + let target = ipv4_route(switch, 10, "10.10.10.1"); + let endpoint = ipv6("fd00:209::1"); + + // Each test starts from a reset switch, so both routers get the first + // table. + let first = Uuid::new_v4(); + client.router_create(&first, &endpoint).await?; + client.route_ipv4_add(&first, &route_update(cidr, &target)).await?; + client.router_delete(&first).await?; + + let second = Uuid::new_v4(); + client.router_create(&second, &endpoint).await?; + assert!( + client.route_ipv4_list(&second, None, None).await?.items.is_empty() + ); + let err = client.route_ipv4_get(&second, &cidr).await.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); + + client.router_delete(&second).await?; + Ok(()) +} + +// Packet tests. A sled on `SLED_PORT` sends a Geneve packet to an address on +// the switch. The switch decapsulates it and routes the inner packet with +// the table that address selects: table 0 for the port's link address, or +// the table of the router whose endpoint it is. This is the +// same path as `nat::test_nat_egress`. + +const SLED_PORT: PhysPort = PhysPort(10); +const SLED_MAC: &str = "11:22:33:44:55:66"; +const SLED_IP: &str = "fd00:1122:7788:101::4"; +/// The link address of `SLED_PORT`. +const LINK_ADDR: &str = "fd00:1122:3344:101::5"; +const A_ENDPOINT: &str = "fd00:1122:3344:101::a"; +const B_ENDPOINT: &str = "fd00:1122:3344:101::b"; +const GW_MAC: &str = "02:aa:bb:cc:dd:ee"; + +async fn add_link_addr(switch: &Switch) -> TestResult { + let (port_id, link_id) = switch.link_id(SLED_PORT).unwrap(); + let entry = types::Ipv6Entry { + addr: LINK_ADDR.parse()?, + tag: switch.client.inner().tag.clone(), + }; + switch.client.link_ipv6_create(&port_id, &link_id, &entry).await?; + Ok(()) +} + +/// Route `cidr` out of `port` in `router`'s table. The port is made an +/// uplink, as decapsulated packets may only leave through uplinks. +async fn add_route( + switch: &Switch, + router: Uuid, + cidr: &str, + port: u16, +) -> TestResult { + let gw = format!("10.10.{port}.1"); + let target = ipv4_route(switch, port, &gw); + switch + .client + .route_ipv4_add(&router, &route_update(cidr.parse()?, &target)) + .await?; + common::add_arp_ipv4(switch, &gw, GW_MAC.parse()?).await?; + switch.set_uplink(PhysPort(port), true).await; + Ok(()) +} + +/// A UDP packet from the sled to `dst`. It carries the outer packet's +/// Ethernet header, since that's what decapsulation leaves. +fn inner_packet(switch: &Switch, dst: &str) -> Packet { + let switch_mac = switch.get_port_mac(SLED_PORT).unwrap().to_string(); + common::gen_udp_packet( + Endpoint::parse(SLED_MAC, "172.16.10.33", 3333).unwrap(), + Endpoint::parse(&switch_mac, dst, 4444).unwrap(), + ) +} + +/// `inner` sent by the sled in a Geneve packet to `outer_dst`. +fn geneve_to(switch: &Switch, outer_dst: &str, inner: &Packet) -> TestPacket { + let switch_mac = switch.get_port_mac(SLED_PORT).unwrap().to_string(); + let payload = inner.deparse().unwrap().to_vec(); + let packet = common::gen_geneve_packet( + Endpoint::parse(SLED_MAC, SLED_IP, 3333).unwrap(), + Endpoint::parse(&switch_mac, outer_dst, geneve::GENEVE_UDP_PORT) + .unwrap(), + eth::ETHER_IPV4, + 1, + &[], + &payload[14..], + ); + TestPacket { packet: Arc::new(packet), port: SLED_PORT } +} + +/// `inner` as it leaves `port` after being routed. +fn forwarded_packet(switch: &Switch, inner: &Packet, port: u16) -> TestPacket { + let mut packet = common::gen_packet_routed(switch, PhysPort(port), inner); + eth::EthHdr::rewrite_dmac(&mut packet, GW_MAC.parse().unwrap()); + TestPacket { packet: Arc::new(packet), port: PhysPort(port) } +} + +/// Create routers A and B with their endpoints. +async fn create_a_and_b( + switch: &Switch, +) -> Result<(Uuid, Uuid), anyhow::Error> { + let (a, b) = (Uuid::new_v4(), Uuid::new_v4()); + switch.client.router_create(&a, &ipv6(A_ENDPOINT)).await?; + switch.client.router_create(&b, &ipv6(B_ENDPOINT)).await?; + Ok((a, b)) +} + +/// One inner prefix, routed out of a different port by each router. +#[tokio::test] +#[ignore] +async fn test_endpoint_selects_router() -> TestResult { + let switch = &*get_switch().await; + add_link_addr(switch).await?; + let (a, b) = create_a_and_b(switch).await?; + let cidr = "10.10.10.0/24"; + add_route(switch, DEFAULT_ROUTER, cidr, 14).await?; + add_route(switch, a, cidr, 15).await?; + add_route(switch, b, cidr, 16).await?; + + let inner = inner_packet(switch, "10.10.10.32"); + for (outer_dst, port) in + [(LINK_ADDR, 14), (A_ENDPOINT, 15), (B_ENDPOINT, 16)] + { + switch.packet_test( + vec![geneve_to(switch, outer_dst, &inner)], + vec![forwarded_packet(switch, &inner, port)], + )?; + } + Ok(()) +} + +/// A router doesn't fall back to the default router's routes. +#[tokio::test] +#[ignore] +async fn test_no_fallback_to_default_router() -> TestResult { + let switch = &*get_switch().await; + add_link_addr(switch).await?; + create_a_and_b(switch).await?; + add_route(switch, DEFAULT_ROUTER, "10.10.10.0/24", 14).await?; + + let inner = inner_packet(switch, "10.10.10.32"); + let mut unreachable = inner.clone(); + common::set_icmp_unreachable(switch, &mut unreachable, SLED_PORT); + switch.packet_test( + vec![geneve_to(switch, A_ENDPOINT, &inner)], + vec![TestPacket { packet: Arc::new(unreachable), port: SERVICE_PORT }], + ) +} + +/// The longest matching prefix is chosen within one router's table only: a +/// longer prefix in another router's table doesn't win. +#[tokio::test] +#[ignore] +async fn test_longest_prefix_within_router() -> TestResult { + let switch = &*get_switch().await; + add_link_addr(switch).await?; + let (a, _) = create_a_and_b(switch).await?; + add_route(switch, DEFAULT_ROUTER, "10.0.0.0/8", 14).await?; + add_route(switch, a, "10.1.0.0/16", 15).await?; + + let inner = inner_packet(switch, "10.1.1.1"); + switch.packet_test( + vec![geneve_to(switch, LINK_ADDR, &inner)], + vec![forwarded_packet(switch, &inner, 14)], + ) +} diff --git a/dpd-client/tests/integration_tests/table_tests.rs b/dpd-client/tests/integration_tests/table_tests.rs index f764ad15..8aed3fde 100644 --- a/dpd-client/tests/integration_tests/table_tests.rs +++ b/dpd-client/tests/integration_tests/table_tests.rs @@ -48,7 +48,7 @@ use crate::integration_tests::common::prelude::*; #[cfg(feature = "multicast")] const IPV4_LPM_SIZE: usize = 7164; // ipv4 forwarding table #[cfg(not(feature = "multicast"))] -const IPV4_LPM_SIZE: usize = 8191; // ipv4 forwarding table +const IPV4_LPM_SIZE: usize = 8190; // ipv4 forwarding table #[cfg(feature = "multicast")] const IPV6_LPM_SIZE: usize = 1023; // ipv6 forwarding table @@ -412,18 +412,18 @@ impl TableTest for RouteV4 { }), replace: false, }; - switch.client.route_ipv4_set(&route).await + switch.client.route_ipv4_set(&DEFAULT_ROUTER, &route).await } async fn delete_entry(switch: &Switch, idx: usize) -> OpResult<()> { let cidr = gen_ipv4_cidr(idx); - switch.client.route_ipv4_delete(&cidr).await + switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await } async fn count_entries(switch: &Switch) -> usize { switch .client - .route_ipv4_list_stream(None) + .route_ipv4_list_stream(&DEFAULT_ROUTER, None) .try_collect::>() .await .unwrap() @@ -456,18 +456,18 @@ impl TableTest for RouteV6 { }, replace: false, }; - switch.client.route_ipv6_set(&route).await + switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await } async fn delete_entry(switch: &Switch, idx: usize) -> OpResult<()> { let cidr = gen_ipv6_cidr(idx); - switch.client.route_ipv6_delete(&cidr).await + switch.client.route_ipv6_delete(&DEFAULT_ROUTER, &cidr).await } async fn count_entries(switch: &Switch) -> usize { switch .client - .route_ipv6_list_stream(None) + .route_ipv6_list_stream(&DEFAULT_ROUTER, None) .try_collect::>() .await .unwrap() diff --git a/dpd-types/versions/src/latest.rs b/dpd-types/versions/src/latest.rs index 3b56eb39..eb145887 100644 --- a/dpd-types/versions/src/latest.rs +++ b/dpd-types/versions/src/latest.rs @@ -171,11 +171,8 @@ pub mod route { pub use crate::v1::route::Ipv6RouteToken; pub use crate::v1::route::Ipv6RouteUpdate; pub use crate::v1::route::Ipv6Routes; - pub use crate::v1::route::RoutePathV4; - pub use crate::v1::route::RoutePathV6; pub use crate::v1::route::RouteSettingsV4; pub use crate::v1::route::RouteSettingsV6; - pub use crate::v1::route::RouteTargetIpv6Path; pub use crate::v3::route::AttachedSubnetToken; pub use crate::v3::route::SubnetPath; @@ -185,7 +182,13 @@ pub mod route { pub use crate::v4::route::RouteTarget; pub use crate::v6::route::Ipv4RouteUpdate; - pub use crate::v6::route::RouteTargetIpv4Path; + + pub use crate::v14::route::DEFAULT_ROUTER; + pub use crate::v14::route::RoutePathV4; + pub use crate::v14::route::RoutePathV6; + pub use crate::v14::route::RouteTargetIpv4Path; + pub use crate::v14::route::RouteTargetIpv6Path; + pub use crate::v14::route::RouterPath; } pub mod serdes { diff --git a/dpd-types/versions/src/lib.rs b/dpd-types/versions/src/lib.rs index 617a58c7..00fbafbc 100644 --- a/dpd-types/versions/src/lib.rs +++ b/dpd-types/versions/src/lib.rs @@ -43,6 +43,8 @@ pub mod v11; pub mod v12; #[path = "allow_ddm_traffic/mod.rs"] pub mod v13; +#[path = "multi_router/mod.rs"] +pub mod v14; #[path = "attached_subnets/mod.rs"] pub mod v3; #[path = "v4_over_v6_routes/mod.rs"] diff --git a/dpd-types/versions/src/multi_router/mod.rs b/dpd-types/versions/src/multi_router/mod.rs new file mode 100644 index 00000000..f8bfc4c4 --- /dev/null +++ b/dpd-types/versions/src/multi_router/mod.rs @@ -0,0 +1,13 @@ +// 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 + +//! Version `MULTI_ROUTER` of the DPD API. +//! +//! Adds routers, each with its own routes and endpoint. Route paths move +//! under `/router/{router_id}/...`; earlier versions act on the default +//! router. + +pub mod route; diff --git a/dpd-types/versions/src/multi_router/route.rs b/dpd-types/versions/src/multi_router/route.rs new file mode 100644 index 00000000..ef378995 --- /dev/null +++ b/dpd-types/versions/src/multi_router/route.rs @@ -0,0 +1,113 @@ +// 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, Ipv6Addr}; + +use crate::v1; +use crate::v1::port::PortId; +use crate::v6; +use oxnet::{Ipv4Net, Ipv6Net}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::v1::link::LinkId; + +/// The logical router used for backwards compatibility, sharing routing table +/// 0 with the underlay routes. This is to be removed when compatibility in a +/// subsequent version. +pub const DEFAULT_ROUTER: Uuid = Uuid::nil(); + +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct RouterPath { + /// The router being addressed. + pub router_id: Uuid, +} + +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct RoutePathV4 { + /// The router being addressed. + pub router_id: Uuid, + /// The IPv4 subnet in CIDR notation whose route entry is returned. + pub cidr: Ipv4Net, +} + +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct RoutePathV6 { + /// The router being addressed. + pub router_id: Uuid, + /// The IPv6 subnet in CIDR notation whose route entry is returned. + pub cidr: Ipv6Net, +} + +/// Represents a single subnet->target route entry with an IPv4 or IPv6 +/// next hop. +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct RouteTargetIpv4Path { + /// The router being addressed. + pub router_id: Uuid, + /// The subnet being routed + pub cidr: Ipv4Net, + /// The switch port to which packets should be sent + pub port_id: PortId, + /// The link to which packets should be sent + pub link_id: LinkId, + /// The next hop in the route (IPv4 or IPv6) + pub tgt_ip: IpAddr, +} + +/// Represents a single subnet->target route entry +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct RouteTargetIpv6Path { + /// The router being addressed. + pub router_id: Uuid, + /// The subnet being routed + pub cidr: Ipv6Net, + /// The switch port to which packets should be sent + pub port_id: PortId, + /// The link to which packets should be sent + pub link_id: LinkId, + /// The next hop in the IPv6 route + pub tgt_ip: Ipv6Addr, +} + +// Paths from earlier versions have no router, and address the default router. + +impl From for RoutePathV4 { + fn from(old: v1::route::RoutePathV4) -> Self { + Self { router_id: DEFAULT_ROUTER, cidr: old.cidr } + } +} + +impl From for RoutePathV6 { + fn from(old: v1::route::RoutePathV6) -> Self { + Self { router_id: DEFAULT_ROUTER, cidr: old.cidr } + } +} + +impl From for RouteTargetIpv4Path { + fn from(old: v6::route::RouteTargetIpv4Path) -> Self { + Self { + router_id: DEFAULT_ROUTER, + cidr: old.cidr, + port_id: old.port_id, + link_id: old.link_id, + tgt_ip: old.tgt_ip, + } + } +} + +impl From for RouteTargetIpv6Path { + fn from(old: v1::route::RouteTargetIpv6Path) -> Self { + Self { + router_id: DEFAULT_ROUTER, + cidr: old.cidr, + port_id: old.port_id, + link_id: old.link_id, + tgt_ip: old.tgt_ip, + } + } +} diff --git a/dpd/p4/metadata.p4 b/dpd/p4/metadata.p4 index c609a269..172608b5 100644 --- a/dpd/p4/metadata.p4 +++ b/dpd/p4/metadata.p4 @@ -29,6 +29,7 @@ struct sidecar_ingress_meta_t { ipv6_addr_t nexthop_ipv6; // ip address of next router bit<10> pkt_type; bit<8> drop_reason; // reason a packet was dropped + bit<8> routing_table_id; // routing table to look up routes in bit<16> l4_src_port; // tcp or udp destination port bit<16> l4_dst_port; // tcp or udp destination port ipv6_addr_t nat_ingress_tgt; // target address for NAT ingress diff --git a/dpd/p4/parser.p4 b/dpd/p4/parser.p4 index 3a2618d0..161f8690 100644 --- a/dpd/p4/parser.p4 +++ b/dpd/p4/parser.p4 @@ -50,6 +50,7 @@ parser IngressParser( meta.orig_dst_ipv4 = 0; meta.pkt_type = 0; meta.drop_reason = 0; + meta.routing_table_id = 0; meta.nat_ingress_csum = 0; meta.resolve_nexthop = false; diff --git a/dpd/p4/sidecar.p4 b/dpd/p4/sidecar.p4 index 07a694ae..7bec7b25 100644 --- a/dpd/p4/sidecar.p4 +++ b/dpd/p4/sidecar.p4 @@ -137,6 +137,12 @@ control Filter( ipv6_ctr.count(); } + action claimv6_rid(bit<8> routing_table_id) { + meta.is_switch_address = true; + meta.routing_table_id = routing_table_id; + ipv6_ctr.count(); + } + // Table of the IPv4 addresses assigned to ports on the switch. table switch_ipv4_addr { @@ -156,7 +162,7 @@ control Filter( hdr.ipv6.dst_addr : exact; ig_intr_md.ingress_port : ternary; } - actions = { claimv6; dropv6; } + actions = { claimv6; claimv6_rid; dropv6; } const size = SWITCH_IPV6_ADDRS_SIZE; counters = ipv6_ctr; @@ -916,6 +922,7 @@ control NatEgress ( control RouterLookupIndex6( inout sidecar_headers_t hdr, + in sidecar_ingress_meta_t meta, inout route6_result_t res ) { DirectCounter>(CounterType_t.PACKETS_AND_BYTES) index_ctr; @@ -984,7 +991,10 @@ control RouterLookupIndex6( } table lookup { - key = { hdr.ipv6.dst_addr: lpm; } + key = { + meta.routing_table_id: exact; + hdr.ipv6.dst_addr: lpm; + } actions = { index; unreachable; } default_action = unreachable; // The table size is incremented by one here just to allow the @@ -1014,6 +1024,7 @@ control RouterLookupIndex6( control RouterLookupIndex4( inout sidecar_headers_t hdr, + in sidecar_ingress_meta_t meta, inout route4_result_t res ) { DirectCounter>(CounterType_t.PACKETS_AND_BYTES) index_ctr; @@ -1105,7 +1116,10 @@ control RouterLookupIndex4( } table lookup { - key = { hdr.ipv4.dst_addr: lpm; } + key = { + meta.routing_table_id: exact; + hdr.ipv4.dst_addr: lpm; + } actions = { index; unreachable; } default_action = unreachable; const size = IPV4_LPM_SIZE; @@ -1268,7 +1282,7 @@ control Router4 ( meta.l4_src_port }) & 0x3f; - lookup_idx.apply(hdr, fwd); + lookup_idx.apply(hdr, meta, fwd); if (!fwd.is_hit) { icmp_error(ICMP_DEST_UNREACH, ICMP_DST_UNREACH_NET); @@ -1413,7 +1427,7 @@ control Router6 ( meta.l4_src_port }) & 0x3f; - lookup_idx.apply(hdr, fwd); + lookup_idx.apply(hdr, meta, fwd); if (!fwd.is_hit) { icmp_error(ICMP6_DST_UNREACH, ICMP6_DST_UNREACH_NOROUTE); diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index 5e108a04..5f8c5e25 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -54,7 +54,7 @@ use dpd_types::route::{ AttachedSubnetToken, Ipv4RouteToken, Ipv4RouteUpdate, Ipv4Routes, Ipv6Route, Ipv6RouteToken, Ipv6RouteUpdate, Ipv6Routes, Route, RoutePathV4, RoutePathV6, RouteTarget, RouteTargetIpv4Path, RouteTargetIpv6Path, - SubnetPath, + RouterPath, SubnetPath, }; use dpd_types::serdes::{ AnLtStatus, Ber, DfeAdaptationState, EncSpeed, LaneMap, RxSigInfo, @@ -104,10 +104,12 @@ use crate::counters; use crate::mcast; use crate::nat; use crate::oxstats; +use crate::router::RouterUuid; +use crate::router::RoutingTableId; use crate::rpw::Task; use crate::switch_port::FixedSideDevice; use crate::types::DpdError; -use crate::{Switch, arp, loopback, ports, route}; +use crate::{Switch, arp, loopback, ports, route, router}; use common::attached_subnet::AttachedSubnetEntry; use common::nat::{Ipv4Nat, Ipv6Nat}; use common::network::{InstanceTarget, MacAddr, NatTarget}; @@ -115,6 +117,7 @@ use common::ports::PortId; use common::ports::QsfpPort; use common::ports::{Ipv4Entry, Ipv6Entry, PortPrbsMode}; use dpd_api::*; +use uuid::Uuid; type ApiServer = dropshot::HttpServer>; @@ -331,9 +334,11 @@ impl DpdApi for DpdApiImpl { async fn route_ipv6_list( rqctx: RequestContext>, + path: Path, query: Query>, ) -> Result>, HttpError> { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let pag_params = query.into_inner(); let max = rqctx.page_limit(&pag_params)?.get(); @@ -342,7 +347,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(Ipv6RouteToken { cidr }) => Some(*cidr), }; - route::get_range_ipv6(switch, previous, max) + route::get_range_ipv6(switch, router, previous, max) .await .map_err(HttpError::from) .and_then(|entries| { @@ -360,8 +365,8 @@ impl DpdApi for DpdApiImpl { path: Path, ) -> Result>, HttpError> { let switch: &Switch = rqctx.context(); - let cidr = path.into_inner().cidr; - route::get_route_ipv6(switch, cidr) + let path = path.into_inner(); + route::get_route_ipv6(switch, path.router_id.into(), path.cidr) .await .map(HttpResponseOk) .map_err(HttpError::from) @@ -369,11 +374,13 @@ impl DpdApi for DpdApiImpl { async fn route_ipv6_add( rqctx: RequestContext>, + path: Path, update: TypedBody, ) -> Result { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let route = update.into_inner(); - route::add_route_ipv6(switch, route.cidr, route.target) + route::add_route_ipv6(switch, router, route.cidr, route.target) .await .map(|_| HttpResponseUpdatedNoContent()) .map_err(HttpError::from) @@ -381,14 +388,22 @@ impl DpdApi for DpdApiImpl { async fn route_ipv6_set( rqctx: RequestContext>, + path: Path, update: TypedBody, ) -> Result { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let route = update.into_inner(); - route::set_route_ipv6(switch, route.cidr, route.target, route.replace) - .await - .map(|_| HttpResponseUpdatedNoContent()) - .map_err(HttpError::from) + route::set_route_ipv6( + switch, + router, + route.cidr, + route.target, + route.replace, + ) + .await + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) } async fn route_ipv6_delete( @@ -396,8 +411,8 @@ impl DpdApi for DpdApiImpl { path: Path, ) -> Result { let switch: &Switch = rqctx.context(); - let cidr = path.into_inner().cidr; - route::delete_route_ipv6(switch, cidr) + let path = path.into_inner(); + route::delete_route_ipv6(switch, path.router_id.into(), path.cidr) .await .map(|_| HttpResponseDeleted()) .map_err(HttpError::from) @@ -414,7 +429,12 @@ impl DpdApi for DpdApiImpl { let link_id = path.link_id; let tgt_ip = path.tgt_ip; route::delete_route_target_ipv6( - switch, subnet, port_id, link_id, tgt_ip, + switch, + path.router_id.into(), + subnet, + port_id, + link_id, + tgt_ip, ) .await .map(|_| HttpResponseDeleted()) @@ -423,9 +443,11 @@ impl DpdApi for DpdApiImpl { async fn route_ipv4_list( rqctx: RequestContext>, + path: Path, query: Query>, ) -> Result>, HttpError> { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let pag_params = query.into_inner(); let max = rqctx.page_limit(&pag_params)?.get(); @@ -434,7 +456,7 @@ impl DpdApi for DpdApiImpl { WhichPage::Next(Ipv4RouteToken { cidr }) => Some(*cidr), }; - route::get_range_ipv4(switch, previous, max) + route::get_range_ipv4(switch, router, previous, max) .await .map_err(HttpError::from) .and_then(|entries| { @@ -452,8 +474,8 @@ impl DpdApi for DpdApiImpl { path: Path, ) -> Result>, HttpError> { let switch: &Switch = rqctx.context(); - let cidr = path.into_inner().cidr; - route::get_route_ipv4(switch, cidr) + let path = path.into_inner(); + route::get_route_ipv4(switch, path.router_id.into(), path.cidr) .await .map(HttpResponseOk) .map_err(HttpError::from) @@ -461,17 +483,21 @@ impl DpdApi for DpdApiImpl { async fn route_ipv4_add( rqctx: RequestContext>, + path: Path, update: TypedBody, ) -> Result { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let route = update.into_inner(); match route.target { RouteTarget::V4(target) => { - route::add_route_ipv4(switch, route.cidr, target).await + route::add_route_ipv4(switch, router, route.cidr, target).await } RouteTarget::V6(target) => { - route::add_route_ipv4_over_ipv6(switch, route.cidr, target) - .await + route::add_route_ipv4_over_ipv6( + switch, router, route.cidr, target, + ) + .await } } .map(|_| HttpResponseUpdatedNoContent()) @@ -480,18 +506,27 @@ impl DpdApi for DpdApiImpl { async fn route_ipv4_set( rqctx: RequestContext>, + path: Path, update: TypedBody, ) -> Result { let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); let route = update.into_inner(); match route.target { RouteTarget::V4(target) => { - route::set_route_ipv4(switch, route.cidr, target, route.replace) - .await + route::set_route_ipv4( + switch, + router, + route.cidr, + target, + route.replace, + ) + .await } RouteTarget::V6(target) => { route::set_route_ipv4_over_ipv6( switch, + router, route.cidr, target, route.replace, @@ -508,8 +543,8 @@ impl DpdApi for DpdApiImpl { path: Path, ) -> Result { let switch: &Switch = rqctx.context(); - let cidr = path.into_inner().cidr; - route::delete_route_ipv4(switch, cidr) + let path = path.into_inner(); + route::delete_route_ipv4(switch, path.router_id.into(), path.cidr) .await .map(|_| HttpResponseDeleted()) .map_err(HttpError::from) @@ -523,6 +558,7 @@ impl DpdApi for DpdApiImpl { let path = path.into_inner(); route::delete_route_target_ipv4( switch, + path.router_id.into(), path.cidr, path.port_id, path.link_id, @@ -1388,8 +1424,10 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let addr = val.into_inner(); - - loopback::add_loopback_ipv6(switch, &addr)?; + // We refuse loopbacks that are already taken by routers. + let route_data = switch.routes.lock().await; + route_data.routers.check_not_endpoint(addr.addr)?; + loopback::add_loopback_ipv6(switch, &addr, RoutingTableId::default())?; Ok(HttpResponseUpdatedNoContent {}) } @@ -1400,11 +1438,64 @@ impl DpdApi for DpdApiImpl { ) -> Result { let switch: &Switch = rqctx.context(); let addr = path.into_inner(); + // A router's endpoint is only removed when deleting the router. + let route_data = switch.routes.lock().await; + route_data.routers.check_not_endpoint(addr.ipv6)?; loopback::delete_loopback_ipv6(switch, &addr.ipv6) .map(|_| HttpResponseDeleted()) .map_err(HttpError::from) } + async fn router_list( + rqctx: RequestContext>, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + Ok(HttpResponseOk(switch.routes.lock().await.routers.list())) + } + + async fn router_get( + rqctx: RequestContext>, + path: Path, + ) -> Result, HttpError> { + let switch: &Switch = rqctx.context(); + let route_data = switch.routes.lock().await; + route_data + .routers + .endpoint(path.into_inner().router_id.into()) + .map(HttpResponseOk) + .map_err(HttpError::from) + } + + async fn router_create( + rqctx: RequestContext>, + path: Path, + endpoint: TypedBody, + ) -> Result { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let mut route_data = switch.routes.lock().await; + route_data + .routers + .create(switch, router, endpoint.into_inner()) + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) + } + + async fn router_delete( + rqctx: RequestContext>, + path: Path, + ) -> Result { + let switch: &Switch = rqctx.context(); + let mut route_data = switch.routes.lock().await; + router::delete( + switch, + &mut route_data, + path.into_inner().router_id.into(), + ) + .map(|_| HttpResponseDeleted()) + .map_err(HttpError::from) + } + async fn nat_ipv6_addresses_list( rqctx: RequestContext>, query: Query>, @@ -1730,6 +1821,11 @@ impl DpdApi for DpdApiImpl { error!(switch.log, "failed to reset ipv6 arp table: {:?}", e); err = Some(e); } + if let Err(e) = router::reset(switch, &mut *switch.routes.lock().await) + { + error!(switch.log, "failed to reset routers: {:?}", e); + err = Some(e); + } if let Err(e) = route::reset(switch).await { error!(switch.log, "failed to reset route data: {:?}", e); err = Some(e); diff --git a/dpd/src/loopback.rs b/dpd/src/loopback.rs index f0a4665c..c8aa1b98 100644 --- a/dpd/src/loopback.rs +++ b/dpd/src/loopback.rs @@ -4,6 +4,7 @@ // // Copyright 2026 Oxide Computer Company +use crate::router::RoutingTableId; use crate::{DpdError, DpdResult, Switch, table}; use aal::AsicError; use common::ports::{Ipv4Entry, Ipv6Entry}; @@ -73,16 +74,30 @@ pub fn delete_loopback_ipv4(switch: &Switch, addr: &Ipv4Addr) -> DpdResult<()> { } /// Add a loopback IPv6 address to the switch. -pub fn add_loopback_ipv6(switch: &Switch, addr: &Ipv6Entry) -> DpdResult<()> { +/// +/// We won't "inherit" a loopback if we're adding a new endpoint. +pub fn add_loopback_ipv6( + switch: &Switch, + addr: &Ipv6Entry, + table_id: RoutingTableId, +) -> DpdResult<()> { let mut loopback_data = switch.loopback.lock().unwrap(); if loopback_data.v6_addrs.contains(addr) { + if table_id != RoutingTableId::default() { + return Err(DpdError::Exists(format!( + "{} is already a loopback", + addr.addr + ))); + } debug!(switch.log, "loopback entry {} already set", addr.addr); return Ok(()); } - match table::port_ip::loopback_ipv6_add(switch, addr.addr) { + match table::port_ip::loopback_ipv6_add(switch, addr.addr, table_id) { Ok(()) => _ = loopback_data.v6_addrs.insert(addr.clone()), - Err(DpdError::Switch(AsicError::Exists)) => { + Err(DpdError::Switch(AsicError::Exists)) + if table_id == RoutingTableId::default() => + { if !loopback_data.v6_addrs.contains(addr) { warn!( switch.log, diff --git a/dpd/src/main.rs b/dpd/src/main.rs index 5fc08ec5..6990a934 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -73,6 +73,7 @@ mod port_map; mod port_settings; mod ports; mod route; +mod router; mod rpw; #[cfg(feature = "tofino_asic")] mod snapshot; @@ -191,6 +192,7 @@ pub struct Switch { pub tables: BTreeMap>, pub counters: BTreeMap>, pub links: Mutex, + // This should be locked before `loopback`. pub routes: TokioMutex, pub arp: Mutex, pub nat: nat::Nat, diff --git a/dpd/src/route.rs b/dpd/src/route.rs index fcbc3854..9ad27276 100644 --- a/dpd/src/route.rs +++ b/dpd/src/route.rs @@ -110,6 +110,7 @@ use std::convert::TryFrom; use std::net::{IpAddr, Ipv6Addr}; use std::ops::Bound; +use aal::AsicError; use dpd_types::link::LinkId; use dpd_types::route::Ipv4Route; use dpd_types::route::Ipv6Route; @@ -119,6 +120,7 @@ use slog::info; use slog::warn; use crate::freemap; +use crate::router::{RouterUuid, Routers, RoutingTableId}; use crate::types::{DpdError, DpdResult}; use crate::{Switch, table}; use common::ports::PortId; @@ -237,6 +239,25 @@ impl From for Route { } } +/// Identifies a route by its routing table and subnet. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RouteKey { + table_id: RoutingTableId, + subnet: IpNet, +} + +impl RouteKey { + fn new(table_id: RoutingTableId, subnet: impl Into) -> Self { + RouteKey { table_id, subnet: subnet.into() } + } +} + +impl std::fmt::Display for RouteKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (routing table {})", self.subnet, self.table_id) + } +} + #[derive(Debug, Eq, PartialEq, Clone)] struct NextHop { asic_port_id: u16, @@ -257,9 +278,18 @@ impl RouteEntry { } } -pub struct RouteData { +#[derive(Default)] +struct RoutingTable { v4: BTreeMap, v6: BTreeMap, +} + +/// The routers and routing tables. +pub struct RouteData { + /// The routers, kept here so that a router and its table change together. + pub routers: Routers, + /// The routing tables, keyed by `RoutingTableId`. + tables: BTreeMap, v4_freemap: freemap::FreeMap, v6_freemap: freemap::FreeMap, } @@ -267,33 +297,57 @@ pub struct RouteData { impl RouteData { pub fn insert( &mut self, - subnet: impl Into, + key: RouteKey, entry: RouteEntry, ) -> Option { - let subnet: IpNet = subnet.into(); - if subnet.is_ipv4() { - self.v4.insert(subnet, entry) + let table = self.tables.entry(key.table_id).or_default(); + if key.subnet.is_ipv4() { + table.v4.insert(key.subnet, entry) } else { - self.v6.insert(subnet, entry) + table.v6.insert(key.subnet, entry) } } - pub fn get(&self, subnet: impl Into) -> Option<&RouteEntry> { - let subnet: IpNet = subnet.into(); - if subnet.is_ipv4() { - self.v4.get(&subnet) + pub fn get(&self, key: RouteKey) -> Option<&RouteEntry> { + let table = self.tables.get(&key.table_id)?; + if key.subnet.is_ipv4() { + table.v4.get(&key.subnet) } else { - self.v6.get(&subnet) + table.v6.get(&key.subnet) } } - pub fn remove(&mut self, subnet: impl Into) -> Option { - let subnet: IpNet = subnet.into(); - if subnet.is_ipv4() { - self.v4.remove(&subnet) + pub fn remove(&mut self, key: RouteKey) -> Option { + let table = self.tables.get_mut(&key.table_id)?; + if key.subnet.is_ipv4() { + table.v4.remove(&key.subnet) } else { - self.v6.remove(&subnet) + table.v6.remove(&key.subnet) + } + } + + /// Remove every route in routing table `table_id` from the switch, then + /// forget the table. If a removal fails, the remaining routes are kept so + /// the delete can be retried. + fn delete_table( + &mut self, + ops: &dyn RouteTableOps, + table_id: RoutingTableId, + ) -> DpdResult<()> { + let Some(table) = self.tables.get(&table_id) else { + return Ok(()); + }; + let keys: Vec = table + .v4 + .keys() + .chain(table.v6.keys()) + .map(|subnet| RouteKey::new(table_id, *subnet)) + .collect(); + for key in keys { + delete_route_locked(ops, self, key)?; } + self.tables.remove(&table_id); + Ok(()) } fn freemap_mut(&mut self, is_ipv4: bool) -> &mut freemap::FreeMap { @@ -327,11 +381,11 @@ trait RouteTableOps { fn delete_target(&self, subnet: IpNet, idx: u16) -> DpdResult<()>; /// Install a `route_index` entry pointing at `[index, index + slots)` - /// of the family inferred from `subnet`. - fn add_index(&self, subnet: IpNet, index: u16, slots: u8) -> DpdResult<()>; + /// of the family inferred from `key`. + fn add_index(&self, key: RouteKey, index: u16, slots: u8) -> DpdResult<()>; - /// Delete the `route_index` entry for `subnet`. - fn delete_index(&self, subnet: IpNet) -> DpdResult<()>; + /// Delete the `route_index` entry for `key`. + fn delete_index(&self, key: RouteKey) -> DpdResult<()>; } impl RouteTableOps for Switch { @@ -383,27 +437,38 @@ impl RouteTableOps for Switch { } } - fn add_index(&self, subnet: IpNet, index: u16, slots: u8) -> DpdResult<()> { - match subnet { + fn add_index(&self, key: RouteKey, index: u16, slots: u8) -> DpdResult<()> { + match key.subnet { + IpNet::V4(v4) => table::route_ipv4::add_route_index( + self, + key.table_id, + &v4, + index, + slots, + ), + IpNet::V6(v6) => table::route_ipv6::add_route_index( + self, + key.table_id, + &v6, + index, + slots, + ), + } + } + + fn delete_index(&self, key: RouteKey) -> DpdResult<()> { + match key.subnet { IpNet::V4(v4) => { - table::route_ipv4::add_route_index(self, &v4, index, slots) + table::route_ipv4::delete_route_index(self, key.table_id, &v4) } IpNet::V6(v6) => { - table::route_ipv6::add_route_index(self, &v6, index, slots) + table::route_ipv6::delete_route_index(self, key.table_id, &v6) } } } - - fn delete_index(&self, subnet: IpNet) -> DpdResult<()> { - match subnet { - IpNet::V4(v4) => table::route_ipv4::delete_route_index(self, &v4), - IpNet::V6(v6) => table::route_ipv6::delete_route_index(self, &v6), - } - } } -// Remove all the data for a given route from both the route_data and -// route_index tables. +// Remove all the data for a given route from the route_data table. // // Because this may be called from the error-recovery path of a failed add-target // operation, not all of the target slots may yet be populated. Thus we require @@ -411,21 +476,16 @@ impl RouteTableOps for Switch { fn cleanup_route( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, - delete_index: bool, + key: RouteKey, entry: RouteEntry, ) -> DpdResult<()> { - // Remove the subnet -> index mapping first, so nobody can reach the - // entries we delete below. - if delete_index { - ops.delete_index(subnet)?; - } - let all_clear = entry .targets .iter() .enumerate() - .map(|(idx, _hop)| ops.delete_target(subnet, entry.index + idx as u16)) + .map(|(idx, _hop)| { + ops.delete_target(key.subnet, entry.index + idx as u16) + }) .all(|rval| rval.is_ok()); // If all of the entries were removed, we can release the table space back @@ -445,17 +505,17 @@ fn cleanup_route( fn finalize_route( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, entry: Option, ) -> DpdResult<()> { let Some(entry) = entry else { return Ok(()) }; - match ops.add_index(subnet, entry.index, entry.slots) { + match ops.add_index(key, entry.index, entry.slots) { Ok(_) => { - route_data.insert(subnet, entry); + route_data.insert(key, entry); Ok(()) } - Err(_) => cleanup_route(ops, route_data, subnet, false, entry), + Err(_) => cleanup_route(ops, route_data, key, entry), } } @@ -529,17 +589,17 @@ fn classify_update( fn unhook_route( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, ) -> DpdResult> { - let Some(old) = route_data.remove(subnet) else { + let Some(old) = route_data.remove(key) else { return Ok(None); }; - if let Err(e) = ops.delete_index(subnet) { + if let Err(e) = ops.delete_index(key) { debug!( ops.log(), - "unhook_route: route_index delete failed for {subnet}: {e:?}" + "unhook_route: route_index delete failed for {key}: {e:?}" ); - route_data.insert(subnet, old); + route_data.insert(key, old); return Err(e); } Ok(Some(old)) @@ -562,37 +622,37 @@ fn unhook_route( fn replace_route_targets( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, targets: Vec, ) -> DpdResult<()> { - debug!(ops.log(), "replacing targets for {subnet} with: {targets:?}"); + debug!(ops.log(), "replacing targets for {key} with: {targets:?}"); // Delete-route path: no classification needed; unhook and free. if targets.is_empty() { - let old_entry = unhook_route(ops, route_data, subnet)?; + let old_entry = unhook_route(ops, route_data, key)?; return match old_entry { - Some(entry) => cleanup_route(ops, route_data, subnet, false, entry), + Some(entry) => cleanup_route(ops, route_data, key, entry), None => Ok(()), }; } // Classify against the current in-core entry *before* unhooking so that // a NoOp replace leaves the dataplane untouched (no LPM miss window). - match classify_update(route_data.get(subnet), &targets) { + match classify_update(route_data.get(key), &targets) { RouteTargetUpdate::NoOp => Ok(()), RouteTargetUpdate::ShrinkInPlace { removed } => { - let old = unhook_route(ops, route_data, subnet)? + let old = unhook_route(ops, route_data, key)? .expect("subset removal requires existing route"); // shrink_in_place reconstructs the in-core target vec from the // compacted ASIC layout rather than caller-supplied order (see // its body for why), so the caller's vec carries no useful // information past this point. drop(targets); - shrink_in_place(ops, route_data, subnet, old, removed) + shrink_in_place(ops, route_data, key, old, removed) } RouteTargetUpdate::Alloc => { - let old_entry = unhook_route(ops, route_data, subnet)?; - alloc_then_swap(ops, route_data, subnet, old_entry, targets) + let old_entry = unhook_route(ops, route_data, key)?; + alloc_then_swap(ops, route_data, key, old_entry, targets) } } } @@ -605,12 +665,12 @@ fn replace_route_targets( fn alloc_then_swap( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, old_entry: Option, targets: Vec, ) -> DpdResult<()> { let slots = targets.len() as u8; - let is_ipv4 = subnet.is_ipv4(); + let is_ipv4 = key.subnet.is_ipv4(); let mut new_entry = match route_data.freemap_mut(is_ipv4).alloc(slots) { Ok(index) => RouteEntry { is_ipv4, @@ -625,7 +685,7 @@ fn alloc_then_swap( ); // Restore the old route_index + in-core entry (or no-op if // there was no old entry). - let _ = finalize_route(ops, route_data, subnet, old_entry); + let _ = finalize_route(ops, route_data, key, old_entry); return Err(e); } }; @@ -634,10 +694,10 @@ fn alloc_then_swap( let mut idx = new_entry.index; for target in targets { - if let Err(e) = ops.add_target(subnet, idx, &target) { + if let Err(e) = ops.add_target(key.subnet, idx, &target) { debug!(ops.log(), "failed to insert {target:?} into route table"); - let _ = cleanup_route(ops, route_data, subnet, false, new_entry); - let _ = finalize_route(ops, route_data, subnet, old_entry); + let _ = cleanup_route(ops, route_data, key, new_entry); + let _ = finalize_route(ops, route_data, key, old_entry); return Err(e); } idx += 1; @@ -645,12 +705,12 @@ fn alloc_then_swap( } // Insert the new subnet->index mapping - match finalize_route(ops, route_data, subnet, Some(new_entry.clone())) { + match finalize_route(ops, route_data, key, Some(new_entry.clone())) { Ok(()) => { // Finally free all of the table space for the original set of // targets if let Some(entry) = old_entry { - let _ = cleanup_route(ops, route_data, subnet, false, entry); + let _ = cleanup_route(ops, route_data, key, entry); } Ok(()) } @@ -659,8 +719,8 @@ fn alloc_then_swap( // We failed to point at the new set of targets. Free all of the // new data and update the route_index table to point at the // original set of targets. - let _ = cleanup_route(ops, route_data, subnet, false, new_entry); - let _ = finalize_route(ops, route_data, subnet, old_entry); + let _ = cleanup_route(ops, route_data, key, new_entry); + let _ = finalize_route(ops, route_data, key, old_entry); Err(e) } } @@ -697,7 +757,7 @@ fn alloc_then_swap( fn shrink_in_place( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, old: RouteEntry, removed: Vec, ) -> DpdResult<()> { @@ -724,29 +784,25 @@ fn shrink_in_place( .as_ref() .expect("tail slot is live at this point") .clone(); - if let Err(e) = ops.delete_target(subnet, base + removed_idx) { + if let Err(e) = ops.delete_target(key.subnet, base + removed_idx) { warn!( ops.log(), "shrink-in-place compact delete failed at slot {}: {e:?}", base + removed_idx ); - restore_after_shrink_failure( - ops, route_data, subnet, &live, old, - ); + restore_after_shrink_failure(ops, route_data, key, &live, old); return Err(e); } live[removed_idx as usize] = None; if let Err(e) = - ops.add_target(subnet, base + removed_idx, &tail_contents) + ops.add_target(key.subnet, base + removed_idx, &tail_contents) { warn!( ops.log(), "shrink-in-place compact add failed at slot {}: {e:?}", base + removed_idx ); - restore_after_shrink_failure( - ops, route_data, subnet, &live, old, - ); + restore_after_shrink_failure(ops, route_data, key, &live, old); return Err(e); } live[removed_idx as usize] = Some(tail_contents); @@ -755,12 +811,12 @@ fn shrink_in_place( } // Step 2: install the route_index pointing at the compacted range. - if let Err(e) = ops.add_index(subnet, base, new_n) { + if let Err(e) = ops.add_index(key, base, new_n) { warn!( ops.log(), - "shrink-in-place index re-add failed for {subnet}: {e:?}" + "shrink-in-place index re-add failed for {key}: {e:?}" ); - restore_after_shrink_failure(ops, route_data, subnet, &live, old); + restore_after_shrink_failure(ops, route_data, key, &live, old); return Err(e); } @@ -771,7 +827,7 @@ fn shrink_in_place( let release_count = old.slots as u16 - new_n as u16; let mut all_clear = true; for offset in 0..release_count { - if ops.delete_target(subnet, release_base + offset).is_err() { + if ops.delete_target(key.subnet, release_base + offset).is_err() { all_clear = false; } } @@ -780,7 +836,7 @@ fn shrink_in_place( } else { warn!( ops.log(), - "shrink-in-place tail cleanup partially failed for {subnet}; \ + "shrink-in-place tail cleanup partially failed for {key}; \ leaking slots in the FreeMap's accounting" ); // Skipping the free() call leaves the slots claimed in the FreeMap's @@ -803,12 +859,12 @@ fn shrink_in_place( .map(|opt| opt.expect("live[0..new_n) is populated post-compaction")) .collect(); let prev = route_data.insert( - subnet, + key, RouteEntry { is_ipv4, index: base, slots: new_n, targets: compacted }, ); debug_assert!( prev.is_none(), - "shrink_in_place insert for {subnet} replaced an unexpected \ + "shrink_in_place insert for {key} replaced an unexpected \ existing entry" ); Ok(()) @@ -822,16 +878,16 @@ fn shrink_in_place( fn restore_after_shrink_failure( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, live: &[Option], old: RouteEntry, ) { - if rollback_shrink(ops, subnet, &old, live) { - route_data.insert(subnet, old); + if rollback_shrink(ops, key, &old, live) { + route_data.insert(key, old); } else { error!( ops.log(), - "shrink-in-place rollback failed for {subnet}; in-core entry \ + "shrink-in-place rollback failed for {key}; in-core entry \ stays cleared and ASIC state may diverge until the next \ control-plane update on this subnet rebuilds it" ); @@ -851,7 +907,7 @@ fn restore_after_shrink_failure( // negligible and the bookkeeping savings on the hot path are worth it. fn rollback_shrink( ops: &dyn RouteTableOps, - subnet: IpNet, + key: RouteKey, old: &RouteEntry, live: &[Option], ) -> bool { @@ -864,20 +920,20 @@ fn rollback_shrink( } let slot = base + i as u16; if slot_live.is_some() - && let Err(e) = ops.delete_target(subnet, slot) + && let Err(e) = ops.delete_target(key.subnet, slot) { error!(ops.log(), "rollback delete failed at slot {slot}: {e:?}"); ok = false; } - if let Err(e) = ops.add_target(subnet, slot, orig) { + if let Err(e) = ops.add_target(key.subnet, slot, orig) { error!(ops.log(), "rollback write failed at slot {slot}: {e:?}"); ok = false; } } - if let Err(e) = ops.add_index(subnet, base, old.slots) { + if let Err(e) = ops.add_index(key, base, old.slots) { error!( ops.log(), - "rollback route_index re-add failed for {subnet}: {e:?}" + "rollback route_index re-add failed for {key}: {e:?}" ); ok = false; } @@ -887,18 +943,18 @@ fn rollback_shrink( fn add_route_locked( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, route: Route, asic_port_id: u16, ) -> DpdResult<()> { - info!(ops.log(), "adding route {subnet} -> {:?}", route.tgt_ip); + info!(ops.log(), "adding route {key} -> {:?}", route.tgt_ip); let max_targets = - if subnet.is_ipv4() { MAX_TARGETS_IPV4 } else { MAX_TARGETS_IPV6 }; + if key.subnet.is_ipv4() { MAX_TARGETS_IPV4 } else { MAX_TARGETS_IPV6 }; // Get the old set of targets that we'll be adding to let mut targets = - route_data.get(subnet).map_or(Vec::new(), |e| e.targets.clone()); + route_data.get(key).map_or(Vec::new(), |e| e.targets.clone()); // Add the new target targets.push(NextHop { asic_port_id, route }); @@ -907,7 +963,7 @@ fn add_route_locked( "exceeded limit of {max_targets} targets for one route" ))) } else { - replace_route_targets(ops, route_data, subnet, targets) + replace_route_targets(ops, route_data, key, targets) } } @@ -915,6 +971,7 @@ fn add_route_locked( // just this single target. async fn add_route( switch: &Switch, + router: RouterUuid, subnet: IpNet, route: Route, ) -> DpdResult<()> { @@ -922,14 +979,15 @@ async fn add_route( switch.link_asic_port_id(route.port_id, route.link_id)?; let mut route_data = switch.routes.lock().await; + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); // Adding the same route multiple times is a harmless no-op - if let Some(entry) = route_data.get(subnet) + if let Some(entry) = route_data.get(key) && entry.targets.iter().any(|hop| hop.route == route) { return Ok(()); } - add_route_locked(switch, &mut route_data, subnet, route, asic_port_id) + add_route_locked(switch, &mut route_data, key, route, asic_port_id) } // Create a new single-path route. @@ -940,6 +998,7 @@ async fn add_route( // is, it is not an error to "replace" a non- existent route. async fn set_route( switch: &Switch, + router: RouterUuid, subnet: IpNet, route: Route, replace: bool, @@ -948,19 +1007,20 @@ async fn set_route( switch.link_asic_port_id(route.port_id, route.link_id)?; let mut route_data = switch.routes.lock().await; - if let Some(entry) = route_data.get(subnet) { + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + if let Some(entry) = route_data.get(key) { // setting the same route multiple times is a harmless no-op if entry.targets.len() == 1 && entry.targets[0].route == route { Ok(()) } else if !replace { Err(DpdError::Exists("route {cidr} already exists".into())) } else { - info!(switch.log, "replacing subnet {subnet}"); + info!(switch.log, "replacing subnet {key}"); let target = vec![NextHop { asic_port_id, route }]; - replace_route_targets(switch, &mut route_data, subnet, target) + replace_route_targets(switch, &mut route_data, key, target) } } else { - add_route_locked(switch, &mut route_data, subnet, route, asic_port_id) + add_route_locked(switch, &mut route_data, key, route, asic_port_id) } } @@ -972,13 +1032,13 @@ async fn set_route( fn delete_route_target_locked( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, route: Route, ) -> DpdResult<()> { - info!(ops.log(), "deleting route {subnet} -> {}", route.tgt_ip); + info!(ops.log(), "deleting route {key} -> {}", route.tgt_ip); // Get set of targets remaining after we remove this entry - let entry = route_data.get(subnet).ok_or({ + let entry = route_data.get(key).ok_or({ debug!(ops.log(), "No such route"); DpdError::Missing("no such route".into()) })?; @@ -992,67 +1052,75 @@ fn delete_route_target_locked( debug!(ops.log(), "target not found"); Err(DpdError::Missing("no such route".into())) } else { - replace_route_targets(ops, route_data, subnet, targets) + replace_route_targets(ops, route_data, key, targets) } } pub async fn add_route_ipv4( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, route: Ipv4Route, ) -> DpdResult<()> { - add_route(switch, IpNet::V4(subnet), route.into()).await + add_route(switch, router, subnet.into(), route.into()).await } pub async fn add_route_ipv4_over_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, route: Ipv6Route, ) -> DpdResult<()> { - add_route(switch, IpNet::V4(subnet), route.into()).await + add_route(switch, router, subnet.into(), route.into()).await } pub async fn add_route_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv6Net, route: Ipv6Route, ) -> DpdResult<()> { - add_route(switch, IpNet::V6(subnet), route.into()).await + add_route(switch, router, subnet.into(), route.into()).await } pub async fn set_route_ipv4( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, route: Ipv4Route, replace: bool, ) -> DpdResult<()> { - set_route(switch, IpNet::V4(subnet), route.into(), replace).await + set_route(switch, router, subnet.into(), route.into(), replace).await } pub async fn set_route_ipv4_over_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, route: Ipv6Route, replace: bool, ) -> DpdResult<()> { - set_route(switch, IpNet::V4(subnet), route.into(), replace).await + set_route(switch, router, subnet.into(), route.into(), replace).await } pub async fn set_route_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv6Net, route: Ipv6Route, replace: bool, ) -> DpdResult<()> { - set_route(switch, IpNet::V6(subnet), route.into(), replace).await + set_route(switch, router, subnet.into(), route.into(), replace).await } pub async fn get_route_ipv4( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, ) -> DpdResult> { let route_data = switch.routes.lock().await; - match route_data.get(IpNet::V4(subnet)) { + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + match route_data.get(key) { None => Err(DpdError::Missing("no such route".into())), Some(entry) => { Ok(entry.targets.iter().map(|t| (&t.route).into()).collect()) @@ -1062,10 +1130,12 @@ pub async fn get_route_ipv4( pub async fn get_route_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv6Net, ) -> DpdResult> { let route_data = switch.routes.lock().await; - match route_data.get(IpNet::V6(subnet)) { + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + match route_data.get(key) { None => Err(DpdError::Missing("no such route".into())), Some(entry) => { Ok(entry.targets.iter().map(|t| (&t.route).into()).collect()) @@ -1073,43 +1143,59 @@ pub async fn get_route_ipv6( } } +// Remove a route from the switch, then forget it. If any switch write fails, +// the route is kept so the delete can be retried; entries already removed by +// an earlier attempt count as removed. fn delete_route_locked( ops: &dyn RouteTableOps, route_data: &mut RouteData, - subnet: IpNet, + key: RouteKey, ) -> DpdResult<()> { - // Get set of targets remaining after we remove this entry + let already_gone = |r: DpdResult<()>| match r { + Err(DpdError::Switch(AsicError::Missing(_))) => Ok(()), + r => r, + }; let entry = route_data - .remove(subnet) + .get(key) + .cloned() .ok_or(DpdError::Missing("no such route".into()))?; - cleanup_route(ops, route_data, subnet, true, entry) + already_gone(ops.delete_index(key))?; + for idx in 0..entry.targets.len() as u16 { + already_gone(ops.delete_target(key.subnet, entry.index + idx))?; + } + route_data.remove(key); + route_data.freemap_mut(entry.is_ipv4).free(entry.index, entry.slots as u16); + Ok(()) } // Delete a route and all of its targets pub async fn delete_route_ipv4( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, ) -> DpdResult<()> { let mut route_data = switch.routes.lock().await; - - delete_route_locked(switch, &mut route_data, IpNet::V4(subnet)) + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + delete_route_locked(switch, &mut route_data, key) } // Delete a route and all of its targets pub async fn delete_route_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv6Net, ) -> DpdResult<()> { let mut route_data = switch.routes.lock().await; - - delete_route_locked(switch, &mut route_data, IpNet::V6(subnet)) + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + delete_route_locked(switch, &mut route_data, key) } // Delete a specific target from a route, removing the route if this is the last // target. pub async fn delete_route_target_ipv4( switch: &Switch, + router: RouterUuid, subnet: Ipv4Net, port_id: PortId, link_id: LinkId, @@ -1119,18 +1205,15 @@ pub async fn delete_route_target_ipv4( Route { tag: String::new(), port_id, link_id, tgt_ip, vlan_id: None }; let mut route_data = switch.routes.lock().await; - delete_route_target_locked( - switch, - &mut route_data, - IpNet::V4(subnet), - route, - ) + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + delete_route_target_locked(switch, &mut route_data, key, route) } // Delete a specific target from a route, removing the route if this is the last // target. pub async fn delete_route_target_ipv6( switch: &Switch, + router: RouterUuid, subnet: Ipv6Net, port_id: PortId, link_id: LinkId, @@ -1145,27 +1228,28 @@ pub async fn delete_route_target_ipv6( }; let mut route_data = switch.routes.lock().await; - delete_route_target_locked( - switch, - &mut route_data, - IpNet::V6(subnet), - route, - ) + let key = RouteKey::new(route_data.routers.table_id(router)?, subnet); + delete_route_target_locked(switch, &mut route_data, key, route) } pub async fn get_range_ipv4( switch: &Switch, + router: RouterUuid, last: Option, max: u32, ) -> DpdResult> { let route_data = switch.routes.lock().await; + let table_id = route_data.routers.table_id(router)?; + let Some(table) = route_data.tables.get(&table_id) else { + return Ok(Vec::new()); + }; let lower = match last { None => Bound::Unbounded, Some(last) => Bound::Excluded(IpNet::V4(last)), }; let mut routes = Vec::new(); - for (subnet, target_list) in route_data + for (subnet, target_list) in table .v4 .range((lower, Bound::Unbounded)) .take(usize::try_from(max).expect("invalid usize")) @@ -1188,17 +1272,22 @@ pub async fn get_range_ipv4( pub async fn get_range_ipv6( switch: &Switch, + router: RouterUuid, last: Option, max: u32, ) -> DpdResult> { let route_data = switch.routes.lock().await; + let table_id = route_data.routers.table_id(router)?; + let Some(table) = route_data.tables.get(&table_id) else { + return Ok(Vec::new()); + }; let lower = match last { None => Bound::Unbounded, Some(last) => Bound::Excluded(IpNet::V6(last)), }; let mut routes = Vec::new(); - for (subnet, target_list) in route_data + for (subnet, target_list) in table .v6 .range((lower, Bound::Unbounded)) .take(usize::try_from(max).expect("invalid usize")) @@ -1230,22 +1319,25 @@ async fn reset_tag(switch: &Switch, tag: &str, ipv4: bool) { // perform any updates after scanning everything to avoid updating the // route_data BTreeMap while we're iterating over it. let mut to_replace = BTreeMap::new(); - let data = if ipv4 { &route_data.v4 } else { &route_data.v6 }; - for (subnet, entry) in data { - let new_targets: Vec = entry - .targets - .iter() - .filter(|t| t.route.tag != tag) - .cloned() - .collect(); - if new_targets.len() != entry.targets.len() { - to_replace.insert(*subnet, new_targets); + for (table_id, table) in route_data.tables.iter() { + let data = if ipv4 { &table.v4 } else { &table.v6 }; + for (subnet, entry) in data { + let new_targets: Vec = entry + .targets + .iter() + .filter(|t| t.route.tag != tag) + .cloned() + .collect(); + if new_targets.len() != entry.targets.len() { + to_replace + .insert(RouteKey::new(*table_id, *subnet), new_targets); + } } } - for (subnet, targets) in to_replace { - debug!(switch.log, "new subnets for {subnet}: {targets:?}"); - let _ = replace_route_targets(switch, &mut route_data, subnet, targets); + for (key, targets) in to_replace { + debug!(switch.log, "new subnets for {key}: {targets:?}"); + let _ = replace_route_targets(switch, &mut route_data, key, targets); } } @@ -1257,23 +1349,29 @@ pub async fn reset_ipv6_tag(switch: &Switch, tag: &str) { reset_tag(switch, tag, false).await } +/// Delete every route in routing table `table_id`. +pub fn delete_table( + switch: &Switch, + route_data: &mut RouteData, + table_id: RoutingTableId, +) -> DpdResult<()> { + route_data.delete_table(switch, table_id) +} + pub async fn reset(switch: &Switch) -> DpdResult<()> { let mut route_data = switch.routes.lock().await; - route_data.v4 = BTreeMap::new(); + route_data.tables.clear(); route_data.v4_freemap.reset(); - table::route_ipv4::reset(switch)?; - - route_data.v6 = BTreeMap::new(); route_data.v6_freemap.reset(); + table::route_ipv4::reset(switch)?; table::route_ipv6::reset(switch)?; - Ok(()) } pub fn init(log: &slog::Logger) -> RouteData { RouteData { - v4: BTreeMap::new(), - v6: BTreeMap::new(), + routers: Routers::default(), + tables: BTreeMap::new(), v4_freemap: freemap::FreeMap::new(log, "route_ipv4"), v6_freemap: freemap::FreeMap::new(log, "route_ipv6"), } @@ -1302,6 +1400,10 @@ mod tests { common::ports::RearPort::new(0).unwrap().into() } + fn default_router_key(subnet: impl Into) -> RouteKey { + RouteKey::new(RoutingTableId::default(), subnet) + } + fn make_route(tgt_ip: IpAddr) -> Route { Route { tag: "test".into(), @@ -1312,8 +1414,8 @@ mod tests { } } - use aal::AsicError; - use std::cell::Cell; + use std::cell::{Cell, RefCell}; + use std::collections::BTreeSet; /// Total size to use for the per-family FreeMap in tests that exercise /// "table full" / "table fragmented" behavior. Small enough to keep @@ -1331,9 +1433,11 @@ mod tests { } /// In-process implementation of `RouteTableOps` used by the route tests. - /// Every on-chip operation is a no-op (returning `Ok`) unless the - /// corresponding failpoint has been armed via [`TestOps::arm`], in which - /// case the configured call returns a synthetic `Switch(SdeError)` and + /// It records which entries are on the fake switch: adding an entry that + /// is already there returns `Exists`, and deleting one that isn't + /// returns `Missing`, as the ASIC does. If the failpoint for an + /// operation has been armed via [`TestOps::arm`], the configured call + /// instead returns a synthetic `Switch(SdeError)`, changes nothing, and /// the failpoint disarms. This lets the route state machine run /// without any ASIC or P4-runtime presence. struct TestOps { @@ -1342,6 +1446,28 @@ mod tests { del_target_fail: Cell>, add_index_fail: Cell>, del_index_fail: Cell>, + /// Installed `route_target` slots, by (is_ipv4, slot). + targets: RefCell>, + indexes: RefCell>, + } + + fn add_entry(set: &RefCell>, key: T) -> DpdResult<()> { + if set.borrow_mut().insert(key) { + Ok(()) + } else { + Err(DpdError::Switch(AsicError::Exists)) + } + } + + fn delete_entry( + set: &RefCell>, + key: &T, + ) -> DpdResult<()> { + if set.borrow_mut().remove(key) { + Ok(()) + } else { + Err(DpdError::Switch(AsicError::Missing("not installed".into()))) + } } impl TestOps { @@ -1358,6 +1484,8 @@ mod tests { del_target_fail: Cell::new(None), add_index_fail: Cell::new(None), del_index_fail: Cell::new(None), + targets: RefCell::default(), + indexes: RefCell::default(), } } @@ -1403,28 +1531,32 @@ mod tests { fn add_target( &self, - _subnet: IpNet, - _idx: u16, + subnet: IpNet, + idx: u16, _target: &NextHop, ) -> DpdResult<()> { - self.check(Op::AddTarget) + self.check(Op::AddTarget)?; + add_entry(&self.targets, (subnet.is_ipv4(), idx)) } - fn delete_target(&self, _subnet: IpNet, _idx: u16) -> DpdResult<()> { - self.check(Op::DelTarget) + fn delete_target(&self, subnet: IpNet, idx: u16) -> DpdResult<()> { + self.check(Op::DelTarget)?; + delete_entry(&self.targets, &(subnet.is_ipv4(), idx)) } fn add_index( &self, - _subnet: IpNet, + key: RouteKey, _index: u16, _slots: u8, ) -> DpdResult<()> { - self.check(Op::AddIndex) + self.check(Op::AddIndex)?; + add_entry(&self.indexes, key) } - fn delete_index(&self, _subnet: IpNet) -> DpdResult<()> { - self.check(Op::DelIndex) + fn delete_index(&self, key: RouteKey) -> DpdResult<()> { + self.check(Op::DelIndex)?; + delete_entry(&self.indexes, &key) } } @@ -1444,7 +1576,7 @@ mod tests { fn install_victim( ops: &dyn RouteTableOps, rd: &mut RouteData, - victim: IpNet, + victim: RouteKey, targets: impl IntoIterator, ) { for tgt in targets { @@ -1468,7 +1600,7 @@ mod tests { match add_route_locked( ops, rd, - cidr, + default_router_key(cidr), make_route(filler), FAKE_ASIC_PORT, ) { @@ -1494,7 +1626,8 @@ mod tests { fillers.len(), ); for f in fillers.iter().step_by(2) { - delete_route_locked(ops, rd, *f).expect("delete filler"); + delete_route_locked(ops, rd, default_router_key(*f)) + .expect("delete filler"); } } @@ -1504,7 +1637,7 @@ mod tests { /// "shared tgt_ip with one survivor" multi-target scenario. struct FamilyFixtures { is_ipv4: bool, - victim: IpNet, + victim: RouteKey, targets: [IpAddr; 4], filler: IpAddr, filler_cidr: fn(u32) -> IpNet, @@ -1513,7 +1646,9 @@ mod tests { fn fixtures_v6() -> FamilyFixtures { FamilyFixtures { is_ipv4: false, - victim: "3fff:dead::/64".parse::().unwrap().into(), + victim: default_router_key( + "3fff:dead::/64".parse::().unwrap(), + ), targets: [ "2001:db8::55:1".parse::().unwrap().into(), "2001:db8::55:2".parse::().unwrap().into(), @@ -1528,7 +1663,9 @@ mod tests { fn fixtures_v4() -> FamilyFixtures { FamilyFixtures { is_ipv4: true, - victim: "172.16.0.0/32".parse::().unwrap().into(), + victim: default_router_key( + "172.16.0.0/32".parse::().unwrap(), + ), targets: [ Ipv4Addr::new(10, 0, 0, 1).into(), Ipv4Addr::new(10, 0, 0, 2).into(), @@ -1570,13 +1707,13 @@ mod tests { /// should treat it as a terminal assertion. fn assert_post_shrink( rd: &mut RouteData, - subnet: IpNet, + key: RouteKey, is_ipv4: bool, expected: &[IpAddr], freed_slots: u16, ) { use std::collections::BTreeSet; - let entry = rd.get(subnet).expect("victim must still exist"); + let entry = rd.get(key).expect("victim must still exist"); let observed: BTreeSet = entry.targets.iter().map(|t| t.route.tgt_ip).collect(); let expected_set: BTreeSet = expected.iter().copied().collect(); @@ -1852,6 +1989,100 @@ mod tests { identity_replace_is_noop_scenario(fixtures_v4()); } + /// Deleting a routing table removes its routes from the switch and from + /// soft state, and leaves other tables' entries for the same prefixes + /// alone. + #[test] + fn delete_table_leaves_other_tables() { + let (ops, mut rd) = make_test_ctx(); + let doomed = RoutingTableId::from(1); + let other = RoutingTableId::from(2); + let v4: Ipv4Net = "192.168.1.0/24".parse().unwrap(); + let v6: Ipv6Net = "fd00:1::/64".parse().unwrap(); + let gw4: IpAddr = "10.0.0.1".parse().unwrap(); + let gw6: IpAddr = "fd00::1".parse().unwrap(); + for table_id in [RoutingTableId::default(), doomed, other] { + install_victim(&ops, &mut rd, RouteKey::new(table_id, v4), [gw4]); + install_victim(&ops, &mut rd, RouteKey::new(table_id, v6), [gw6]); + } + + rd.delete_table(&ops, doomed).unwrap(); + assert!(!rd.tables.contains_key(&doomed)); + for subnet in [IpNet::from(v4), IpNet::from(v6)] { + let gone = RouteKey::new(doomed, subnet); + assert!(!ops.indexes.borrow().contains(&gone)); + for table_id in [RoutingTableId::default(), other] { + let key = RouteKey::new(table_id, subnet); + assert!(rd.get(key).is_some()); + assert!(ops.indexes.borrow().contains(&key)); + } + } + + // Deleting it again does nothing. + rd.delete_table(&ops, doomed).unwrap(); + } + + fn install_route( + rd: &mut RouteData, + ops: &TestOps, + table_id: RoutingTableId, + ) { + let v4: Ipv4Net = "192.168.1.0/24".parse().unwrap(); + let gw: IpAddr = "10.0.0.1".parse().unwrap(); + let gw2: IpAddr = "10.0.0.2".parse().unwrap(); + install_victim(ops, rd, RouteKey::new(table_id, v4), [gw, gw2]); + } + + /// A failed index delete keeps the route, and a retry removes it. + #[test] + fn delete_table_keeps_route_when_index_delete_fails() { + let (ops, mut rd) = make_test_ctx(); + let table_id = RoutingTableId::from(1); + install_route(&mut rd, &ops, table_id); + let key = RouteKey::new( + table_id, + "192.168.1.0/24".parse::().unwrap(), + ); + + ops.arm(Op::DelIndex, 0); + assert!(rd.delete_table(&ops, table_id).is_err()); + assert!(rd.get(key).is_some(), "route must be kept for a retry"); + + rd.delete_table(&ops, table_id).expect("retry must succeed"); + assert!(!rd.tables.contains_key(&table_id)); + assert!(ops.indexes.borrow().is_empty()); + assert!(ops.targets.borrow().is_empty()); + } + + /// The first attempt removes the index and one target before failing, + /// so the retry only succeeds if it treats those as already removed. + #[test] + fn delete_table_keeps_route_when_target_delete_fails() { + let (ops, mut rd) = make_test_ctx(); + let table_id = RoutingTableId::from(1); + install_route(&mut rd, &ops, table_id); + let key = RouteKey::new( + table_id, + "192.168.1.0/24".parse::().unwrap(), + ); + let entry = rd.get(key).unwrap().clone(); + + ops.arm(Op::DelTarget, 1); + assert!(rd.delete_table(&ops, table_id).is_err()); + assert_eq!(rd.get(key), Some(&entry), "route must be kept for a retry"); + assert!(!ops.indexes.borrow().contains(&key)); + assert_eq!(ops.targets.borrow().len(), 1); + + rd.delete_table(&ops, table_id).expect("retry must succeed"); + assert!(!rd.tables.contains_key(&table_id)); + assert!(ops.targets.borrow().is_empty()); + // The slots are free again only after the retry succeeded. + assert_eq!( + rd.freemap_mut(true).alloc(entry.slots).unwrap(), + entry.index + ); + } + /// A same-length, non-subset replace (one target swapped for another) /// must take the alloc-then-swap path: the new set isn't ⊆ old, so /// shrink-in-place doesn't apply. Verifies the dispatcher routes diff --git a/dpd/src/router.rs b/dpd/src/router.rs new file mode 100644 index 00000000..b4b163f7 --- /dev/null +++ b/dpd/src/router.rs @@ -0,0 +1,204 @@ +// 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::collections::BTreeMap; +use std::net::Ipv6Addr; + +use common::ports::Ipv6Entry; +use uuid::Uuid; + +use dpd_types::route::DEFAULT_ROUTER; + +use crate::route::{self, RouteData}; +use crate::types::{DpdError, DpdResult}; +use crate::{Switch, loopback}; + +/// A router's uuid, as used in the API. The nil uuid (`DEFAULT_ROUTER`, also +/// `RouterUuid::default()`) names table 0. +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +pub struct RouterUuid(Uuid); + +impl RouterUuid { + pub fn is_default(&self) -> bool { + self.0 == DEFAULT_ROUTER + } +} + +impl From for RouterUuid { + fn from(uuid: Uuid) -> Self { + RouterUuid(uuid) + } +} + +impl From for Uuid { + fn from(router: RouterUuid) -> Self { + router.0 + } +} + +impl std::fmt::Display for RouterUuid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The number of a routing table on the switch. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct RoutingTableId(u8); + +/// The routing table for underlay routes and the `DEFAULT_ROUTER`. +impl Default for RoutingTableId { + fn default() -> Self { + RoutingTableId(0) + } +} + +impl From for RoutingTableId { + fn from(table_id: u8) -> Self { + RoutingTableId(table_id) + } +} + +impl From for u8 { + fn from(table_id: RoutingTableId) -> Self { + table_id.0 + } +} + +impl std::fmt::Display for RoutingTableId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A logical router: its endpoint and its routing table id. +#[derive(Clone, Copy)] +struct Router { + endpoint: Ipv6Addr, + table_id: RoutingTableId, +} + +/// The logical routers keyed by their control-plane uuid. +#[derive(Default)] +pub struct Routers(BTreeMap); + +impl Routers { + /// The routing table used by `router_uuid`. + pub fn table_id( + &self, + router_uuid: RouterUuid, + ) -> DpdResult { + if router_uuid.is_default() { + return Ok(RoutingTableId::default()); + } + self.0.get(&router_uuid).map(|r| r.table_id).ok_or_else(|| { + DpdError::Missing(format!("no such router {router_uuid}")) + }) + } + + /// Checks if endpoint address is taken. + pub fn check_not_endpoint(&self, addr: Ipv6Addr) -> DpdResult<()> { + match self.0.iter().find(|(_, r)| r.endpoint == addr) { + Some((router, _)) => Err(DpdError::Exists(format!( + "{addr} is the endpoint of router {router}" + ))), + None => Ok(()), + } + } + + /// Lists endpoints by their uuids. + pub fn list(&self) -> BTreeMap { + self.0 + .iter() + .map(|(router, r)| ((*router).into(), r.endpoint)) + .collect() + } + + /// Returns the endpoint of `router_uuid`. + pub fn endpoint(&self, router_uuid: RouterUuid) -> DpdResult { + self.0.get(&router_uuid).map(|r| r.endpoint).ok_or_else(|| { + DpdError::Missing(format!("no such router {router_uuid}")) + }) + } + + /// Create `router`, claiming `endpoint` as a loopback for the router's + /// routing table. + pub fn create( + &mut self, + switch: &Switch, + router: RouterUuid, + endpoint: Ipv6Addr, + ) -> DpdResult<()> { + if router.is_default() { + return Err(DpdError::Invalid(format!( + "router {router} names table 0, which can't be created" + ))); + } + if let Some(r) = self.0.get(&router) { + if r.endpoint == endpoint { + return Ok(()); + } + return Err(DpdError::Exists(format!( + "router {router} already has endpoint {}", + r.endpoint + ))); + } + let table_id = (1..=u8::MAX) + .map(RoutingTableId::from) + .find(|table_id| self.0.values().all(|r| r.table_id != *table_id)) + .ok_or_else(|| { + DpdError::TableFull("no routing tables available".into()) + })?; + let entry = Ipv6Entry { tag: router.to_string(), addr: endpoint }; + loopback::add_loopback_ipv6(switch, &entry, table_id)?; + self.0.insert(router, Router { endpoint, table_id }); + Ok(()) + } +} + +/// Delete `router` along with its endpoint and routes. +/// +/// The router is forgotten only once everything under it has been removed, +/// so its table is never given to another router while entries for it +/// remain. If a removal fails, the router still exists and the delete can be +/// retried. +pub fn delete( + switch: &Switch, + route_data: &mut RouteData, + router: RouterUuid, +) -> DpdResult<()> { + if router.is_default() { + return Err(DpdError::Invalid(format!( + "router {router} names table 0, which can't be deleted" + ))); + } + remove(switch, route_data, router) +} + +/// Delete every router. +pub fn reset(switch: &Switch, route_data: &mut RouteData) -> DpdResult<()> { + let all: Vec = route_data.routers.0.keys().copied().collect(); + for router in all { + remove(switch, route_data, router)?; + } + Ok(()) +} + +fn remove( + switch: &Switch, + route_data: &mut RouteData, + router: RouterUuid, +) -> DpdResult<()> { + let Some(&Router { endpoint, table_id }) = + route_data.routers.0.get(&router) + else { + return Ok(()); + }; + loopback::delete_loopback_ipv6(switch, &endpoint)?; + route::delete_table(switch, route_data, table_id)?; + route_data.routers.0.remove(&router); + Ok(()) +} diff --git a/dpd/src/table/port_ip.rs b/dpd/src/table/port_ip.rs index 322493a5..1ee033dc 100644 --- a/dpd/src/table/port_ip.rs +++ b/dpd/src/table/port_ip.rs @@ -9,6 +9,7 @@ use std::convert::TryInto; use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use crate::router::RoutingTableId; use slog::error; use slog::info; @@ -46,6 +47,8 @@ enum ActionV4 { enum ActionV6 { #[action_xlate(name = "claimv6")] ClaimIpv6, + #[action_xlate(name = "claimv6_rid")] + ClaimIpv6Rid { routing_table_id: u8 }, #[action_xlate(name = "dropv6")] DropIpv6, } @@ -119,16 +122,29 @@ pub fn loopback_ipv4_delete(s: &Switch, ipv4: Ipv4Addr) -> DpdResult<()> { }) } -pub fn loopback_ipv6_add(s: &Switch, ipv6: Ipv6Addr) -> DpdResult<()> { +pub fn loopback_ipv6_add( + s: &Switch, + ipv6: Ipv6Addr, + table_id: RoutingTableId, +) -> DpdResult<()> { let claim_key = Ipv6MatchKey { dst_addr: ipv6, in_port: MatchMask { val: 0u16.into(), mask: 0u16.into() }, }; - s.table_entry_add(TableType::PortAddrIpv6, &claim_key, &ActionV6::ClaimIpv6) - .map(|_| info!(s.log, "added ipv6 loopback"; "addr" => %ipv6)) + + let action = + ActionV6::ClaimIpv6Rid { routing_table_id: u8::from(table_id) }; + + s.table_entry_add(TableType::PortAddrIpv6, &claim_key, &action) + .map(|_| { + info!(s.log, "added ipv6 loopback"; + "addr" => %ipv6, + "routing_table_id" => %table_id) + }) .inspect_err(|e| { error!(s.log, "failed to add ipv6 loopback"; "addr" => %ipv6, + "routing_table_id" => %table_id, "error" => %e); }) } diff --git a/dpd/src/table/route_ipv4.rs b/dpd/src/table/route_ipv4.rs index e5b9d79e..7a5c7dd9 100644 --- a/dpd/src/table/route_ipv4.rs +++ b/dpd/src/table/route_ipv4.rs @@ -4,6 +4,7 @@ // // Copyright 2026 Oxide Computer Company +use crate::router::RoutingTableId; use dpd_types::table; use std::convert::TryInto; use std::net::Ipv4Addr; @@ -41,6 +42,8 @@ enum RouteAction { // Used to identify entries in the route->index table #[derive(MatchParse, Hash, Debug)] struct RouteKey { + #[match_xlate(type = "value")] + routing_table_id: u8, #[match_xlate(type = "lpm")] dst_addr: Ipv4Net, } @@ -55,17 +58,20 @@ enum IndexAction { /// Add an entry to the route->index table pub fn add_route_index( s: &Switch, + table_id: RoutingTableId, cidr: &Ipv4Net, idx: u16, slots: u8, ) -> DpdResult<()> { let action_data = IndexAction::Index { idx, slots }; - let match_key = RouteKey { dst_addr: *cidr }; + let match_key = + RouteKey { routing_table_id: u8::from(table_id), dst_addr: *cidr }; match s.table_entry_add(TableType::RouteIdxIpv4, &match_key, &action_data) { Ok(()) => { info!(s.log, "added ipv4 route index"; + "routing_table_id" => %table_id, "route" => %cidr, "index" => %idx, "slots" => %slots); @@ -73,6 +79,7 @@ pub fn add_route_index( } Err(e) => { error!(s.log, "failed to add ipv4 route index"; + "routing_table_id" => %table_id, "route" => %cidr, "index" => %idx, "slots" => %slots, @@ -83,13 +90,23 @@ pub fn add_route_index( } /// Remove an entry from the route->index table -pub fn delete_route_index(s: &Switch, cidr: &Ipv4Net) -> DpdResult<()> { - let match_key = RouteKey { dst_addr: *cidr }; +pub fn delete_route_index( + s: &Switch, + table_id: RoutingTableId, + cidr: &Ipv4Net, +) -> DpdResult<()> { + let match_key = + RouteKey { routing_table_id: u8::from(table_id), dst_addr: *cidr }; s.table_entry_del(TableType::RouteIdxIpv4, &match_key) - .map(|_| info!(s.log, "deleted ipv4 index"; "route" => %cidr)) + .map(|_| { + info!(s.log, "deleted ipv4 index"; + "routing_table_id" => %table_id, + "route" => %cidr) + }) .map_err(|e| { error!(s.log, "failed to delete ipv4 index"; + "routing_table_id" => %table_id, "route" => %cidr, "error" => %e); e diff --git a/dpd/src/table/route_ipv6.rs b/dpd/src/table/route_ipv6.rs index 81dc2ec3..1196b4bf 100644 --- a/dpd/src/table/route_ipv6.rs +++ b/dpd/src/table/route_ipv6.rs @@ -4,6 +4,7 @@ // // Copyright 2026 Oxide Computer Company +use crate::router::RoutingTableId; use dpd_types::table; use std::convert::TryInto; use std::net::Ipv6Addr; @@ -36,6 +37,8 @@ enum RouteAction { // Used to identify entries in the route->index table #[derive(MatchParse, Hash, Debug)] struct RouteKey { + #[match_xlate(type = "value")] + routing_table_id: u8, #[match_xlate(type = "lpm")] dst_addr: Ipv6Net, } @@ -50,17 +53,20 @@ enum IndexAction { /// Add an entry to the route->index table pub fn add_route_index( s: &Switch, + table_id: RoutingTableId, cidr: &Ipv6Net, idx: u16, slots: u8, ) -> DpdResult<()> { let action_data = IndexAction::Index { idx, slots }; - let match_key = RouteKey { dst_addr: *cidr }; + let match_key = + RouteKey { routing_table_id: u8::from(table_id), dst_addr: *cidr }; match s.table_entry_add(TableType::RouteIdxIpv6, &match_key, &action_data) { Ok(()) => { info!(s.log, "added ipv6 route index"; + "routing_table_id" => %table_id, "route" => %cidr, "index" => %idx, "slots" => %slots); @@ -68,6 +74,7 @@ pub fn add_route_index( } Err(e) => { error!(s.log, "failed to add ipv6 route index"; + "routing_table_id" => %table_id, "route" => %cidr, "index" => %idx, "slots" => %slots, @@ -78,13 +85,23 @@ pub fn add_route_index( } /// Remove an entry from the route->index table -pub fn delete_route_index(s: &Switch, cidr: &Ipv6Net) -> DpdResult<()> { - let match_key = RouteKey { dst_addr: *cidr }; +pub fn delete_route_index( + s: &Switch, + table_id: RoutingTableId, + cidr: &Ipv6Net, +) -> DpdResult<()> { + let match_key = + RouteKey { routing_table_id: u8::from(table_id), dst_addr: *cidr }; s.table_entry_del(TableType::RouteIdxIpv6, &match_key) - .map(|_| info!(s.log, "deleted ipv6 index"; "route" => %cidr)) + .map(|_| { + info!(s.log, "deleted ipv6 index"; + "routing_table_id" => %table_id, + "route" => %cidr) + }) .map_err(|e| { error!(s.log, "failed to delete ipv6 index"; + "routing_table_id" => %table_id, "route" => %cidr, "error" => %e); e diff --git a/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub b/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub new file mode 100644 index 00000000..c886f19e --- /dev/null +++ b/openapi/dpd/dpd-13.0.0-5db8bd.json.gitstub @@ -0,0 +1 @@ +2df9101e95757037f2cc4e31e8bf00fd9e79efc7:openapi/dpd/dpd-13.0.0-5db8bd.json diff --git a/openapi/dpd/dpd-13.0.0-5db8bd.json b/openapi/dpd/dpd-14.0.0-152d98.json similarity index 97% rename from openapi/dpd/dpd-13.0.0-5db8bd.json rename to openapi/dpd/dpd-14.0.0-152d98.json index 22694237..ba2861f2 100644 --- a/openapi/dpd/dpd-13.0.0-5db8bd.json +++ b/openapi/dpd/dpd-14.0.0-152d98.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "13.0.0" + "version": "14.0.0" }, "paths": { "/all-settings": { @@ -4827,12 +4827,157 @@ } } }, - "/route/ipv4": { + "/router": { "get": { - "summary": "Fetch the configured IPv4 routes, mapping IPv4 CIDR blocks to the switch port", - "description": "used for sending out that traffic, and optionally a gateway.", + "summary": "List the routers with their endpoints.", + "operationId": "router_list", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_ipv6", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "ipv6" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/router/{router_id}": { + "get": { + "summary": "Get a router's endpoint.", + "operationId": "router_get", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "ipv6", + "type": "string", + "format": "ipv6" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Create a router with the given endpoint. An address can be the", + "description": "endpoint of only one router, and a router's endpoint can't be changed. The default router's uuid names table 0 and can't be used.", + "operationId": "router_create", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "ipv6", + "type": "string", + "format": "ipv6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Delete a router, along with its endpoint and routes. The default", + "description": "router's uuid names table 0 and can't be used.", + "operationId": "router_delete", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/router/{router_id}/route/ipv4": { + "get": { + "summary": "Fetch the IPv4 routes configured on the given router, mapping IPv4", + "description": "CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", "operationId": "route_ipv4_list", "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "in": "query", "name": "limit", @@ -4877,9 +5022,21 @@ } }, "put": { - "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6).", - "description": "This call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", + "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", + "description": "the given router.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", "operationId": "route_ipv4_set", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "requestBody": { "content": { "application/json": { @@ -4903,9 +5060,21 @@ } }, "post": { - "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6).", - "description": "This call can be used to create a new single-path route or to add new targets to a multipath route.", + "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", + "description": "the given router.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", "operationId": "route_ipv4_add", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "requestBody": { "content": { "application/json": { @@ -4929,9 +5098,9 @@ } } }, - "/route/ipv4/{cidr}": { + "/router/{router_id}/route/ipv4/{cidr}": { "get": { - "summary": "Get the configured route for the given IPv4 subnet.", + "summary": "Get the configured route for the given IPv4 subnet on the given router.", "operationId": "route_ipv4_get", "parameters": [ { @@ -4942,6 +5111,16 @@ "schema": { "$ref": "#/components/schemas/Ipv4Net" } + }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -4968,7 +5147,7 @@ } }, "delete": { - "summary": "Remove all targets for the given subnet", + "summary": "Remove all targets for the given subnet on the given router.", "operationId": "route_ipv4_delete", "parameters": [ { @@ -4979,6 +5158,16 @@ "schema": { "$ref": "#/components/schemas/Ipv4Net" } + }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -4994,9 +5183,10 @@ } } }, - "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}": { + "/router/{router_id}/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}": { "delete": { - "summary": "Remove a single target for the given IPv4 subnet (IPv4 or IPv6 next hop)", + "summary": "Remove a single target for the given IPv4 subnet on the given router", + "description": "(IPv4 or IPv6 next hop).", "operationId": "route_ipv4_delete_target", "parameters": [ { @@ -5026,6 +5216,16 @@ "$ref": "#/components/schemas/PortId" } }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "in": "path", "name": "tgt_ip", @@ -5050,12 +5250,22 @@ } } }, - "/route/ipv6": { + "/router/{router_id}/route/ipv6": { "get": { - "summary": "Fetch the configured IPv6 routes, mapping IPv6 CIDR blocks to the switch port", - "description": "used for sending out that traffic, and optionally a gateway.", + "summary": "Fetch the IPv6 routes configured on the given router, mapping IPv6", + "description": "CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", "operationId": "route_ipv6_list", "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "in": "query", "name": "limit", @@ -5100,9 +5310,21 @@ } }, "put": { - "summary": "Route an IPv6 subnet to a link and a nexthop gateway.", - "description": "This call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", + "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the given", + "description": "router.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", "operationId": "route_ipv6_set", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "requestBody": { "content": { "application/json": { @@ -5126,9 +5348,21 @@ } }, "post": { - "summary": "Route an IPv6 subnet to a link and a nexthop gateway.", - "description": "This call can be used to create a new single-path route or to add new targets to a multipath route.", + "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the given", + "description": "router.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", "operationId": "route_ipv6_add", + "parameters": [ + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "requestBody": { "content": { "application/json": { @@ -5152,9 +5386,9 @@ } } }, - "/route/ipv6/{cidr}": { + "/router/{router_id}/route/ipv6/{cidr}": { "get": { - "summary": "Get a single IPv6 route, by its IPv6 CIDR block.", + "summary": "Get a single IPv6 route on the given router, by its IPv6 CIDR block.", "operationId": "route_ipv6_get", "parameters": [ { @@ -5165,6 +5399,16 @@ "schema": { "$ref": "#/components/schemas/Ipv6Net" } + }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -5191,7 +5435,7 @@ } }, "delete": { - "summary": "Remove an IPv6 route, by its IPv6 CIDR block.", + "summary": "Remove an IPv6 route on the given router, by its IPv6 CIDR block.", "operationId": "route_ipv6_delete", "parameters": [ { @@ -5202,6 +5446,16 @@ "schema": { "$ref": "#/components/schemas/Ipv6Net" } + }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -5217,9 +5471,9 @@ } } }, - "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}": { + "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}": { "delete": { - "summary": "Remove a single target for the given IPv6 subnet", + "summary": "Remove a single target for the given IPv6 subnet on the given router.", "operationId": "route_ipv6_delete_target", "parameters": [ { @@ -5249,10 +5503,20 @@ "$ref": "#/components/schemas/PortId" } }, + { + "in": "path", + "name": "router_id", + "description": "The router being addressed.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "in": "path", "name": "tgt_ip", - "description": "The next hop in the IPv4 route", + "description": "The next hop in the IPv6 route", "required": true, "schema": { "type": "string", diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index d1a5660e..20e0c968 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-13.0.0-5db8bd.json \ No newline at end of file +dpd-14.0.0-152d98.json \ No newline at end of file diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 57ceccdf..02f7d72f 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -24,6 +24,7 @@ reqwest.workspace = true slog.workspace = true tabwriter.workspace = true tokio.workspace = true +uuid.workspace = true [dev-dependencies] expectorate.workspace = true diff --git a/swadm/src/main.rs b/swadm/src/main.rs index b5fd1221..e64e5bfb 100644 --- a/swadm/src/main.rs +++ b/swadm/src/main.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use anyhow::Context; use clap::{Parser, Subcommand, ValueEnum}; +use uuid::Uuid; use dpd_client::Client; use dpd_client::ClientState; @@ -25,6 +26,7 @@ mod counters; mod link; mod nat; mod route; +mod router; mod snapshot; mod switchport; mod table; @@ -55,6 +57,9 @@ enum Commands { cmd: arp::Arp, }, Route { + /// router to operate on (nil uuid = default) + #[clap(long, global = true, default_value_t = Uuid::nil())] + router_id: Uuid, #[command(subcommand)] cmd: route::Route, }, @@ -62,6 +67,10 @@ enum Commands { #[command(subcommand)] cmd: addr::Addr, }, + Router { + #[command(subcommand)] + cmd: router::Router, + }, Nat { #[command(subcommand)] cmd: nat::Nat, @@ -237,8 +246,11 @@ async fn main_impl() -> anyhow::Result<()> { match opts.cmd { Commands::DpdBuildInfo => build_info(&client).await, Commands::Arp { cmd: a } => arp::arp_cmd(&client, a).await, - Commands::Route { cmd: r } => route::route_cmd(&client, r).await, + Commands::Route { router_id, cmd: r } => { + route::route_cmd(&client, router_id, r).await + } Commands::Addr { cmd: p } => addr::addr_cmd(&client, p).await, + Commands::Router { cmd } => router::router_cmd(&client, cmd).await, Commands::AttachedSubnet { cmd: p } => { attached::attsub_cmd(&client, p).await } diff --git a/swadm/src/route.rs b/swadm/src/route.rs index 61ec1bcc..af91bc85 100644 --- a/swadm/src/route.rs +++ b/swadm/src/route.rs @@ -15,6 +15,7 @@ use colored::*; use futures::stream::TryStreamExt; use oxnet::{IpNet, Ipv4Net, Ipv6Net}; use tabwriter::TabWriter; +use uuid::Uuid; use dpd_client::Client; use dpd_client::ClientInfo; @@ -142,6 +143,7 @@ fn print_ipv6_route( async fn route_list( client: &Client, + router_id: Uuid, family: Option, ) -> anyhow::Result<()> { let (v4, v6) = match family { @@ -156,7 +158,7 @@ async fn route_list( print_route_header(&mut tw)?; if v4 { let routes: Vec = client - .route_ipv4_list_stream(None) + .route_ipv4_list_stream(&router_id, None) .try_collect() .await .context("failed to get IPv4 routes")?; @@ -166,7 +168,7 @@ async fn route_list( } if v6 { let routes: Vec = client - .route_ipv6_list_stream(None) + .route_ipv6_list_stream(&router_id, None) .try_collect() .await .context("failed to get IPv6 routes")?; @@ -177,13 +179,17 @@ async fn route_list( tw.flush().map_err(|e| e.into()) } -async fn route_get(client: &Client, cidr: IpNet) -> anyhow::Result<()> { +async fn route_get( + client: &Client, + router_id: Uuid, + cidr: IpNet, +) -> anyhow::Result<()> { let mut tw = TabWriter::new(stdout()); print_route_header(&mut tw)?; match &cidr { IpNet::V4(c) => { let targets = client - .route_ipv4_get(c) + .route_ipv4_get(&router_id, c) .await .context("failed to get IPv4 route")? .into_inner(); @@ -191,7 +197,7 @@ async fn route_get(client: &Client, cidr: IpNet) -> anyhow::Result<()> { } IpNet::V6(c) => { let targets = client - .route_ipv6_get(c) + .route_ipv6_get(&router_id, c) .await .context("failed to get IPv6 route")? .into_inner(); @@ -203,6 +209,7 @@ async fn route_get(client: &Client, cidr: IpNet) -> anyhow::Result<()> { async fn route_add( client: &Client, + router_id: Uuid, cidr: IpNet, link_path: LinkPath, gw: IpAddr, @@ -231,27 +238,29 @@ async fn route_add( } }; client - .route_ipv4_add(&types::Ipv4RouteUpdate { - cidr, - target, - replace: false, - }) + .route_ipv4_add( + &router_id, + &types::Ipv4RouteUpdate { cidr, target, replace: false }, + ) .await .context("adding IPv4 route") .map(|_| ()) } (IpAddr::V6(tgt_ip), IpNet::V6(cidr)) => client - .route_ipv6_add(&types::Ipv6RouteUpdate { - cidr, - target: types::Ipv6Route { - tag: client.inner().tag.clone(), - port_id: link_path.port_id, - link_id: link_path.link_id, - tgt_ip, - vlan_id, + .route_ipv6_add( + &router_id, + &types::Ipv6RouteUpdate { + cidr, + target: types::Ipv6Route { + tag: client.inner().tag.clone(), + port_id: link_path.port_id, + link_id: link_path.link_id, + tgt_ip, + vlan_id, + }, + replace: false, }, - replace: false, - }) + ) .await .context("adding IPv6 route") .map(|_| ()), @@ -263,6 +272,7 @@ async fn route_add( async fn route_del( client: &Client, + router_id: Uuid, cidr: IpNet, link_path: Option, gw: Option, @@ -279,13 +289,13 @@ async fn route_del( let port = link_path.port_id; let link = link_path.link_id; client - .route_ipv4_delete_target(&c, &port, &link, &gw) + .route_ipv4_delete_target(&router_id, &c, &port, &link, &gw) .await .context("deleting IPv4 route target") .map(|_| ()) } None => client - .route_ipv4_delete(&c) + .route_ipv4_delete(&router_id, &c) .await .context("deleting IPv4 route") .map(|_| ()), @@ -293,7 +303,7 @@ async fn route_del( IpNet::V6(c) => match gw { Some(_) => Err(anyhow!("not supported")), None => client - .route_ipv6_delete(&c) + .route_ipv6_delete(&router_id, &c) .await .context("deleting IPv6 route") .map(|_| ()), @@ -301,15 +311,19 @@ async fn route_del( } } -pub async fn route_cmd(client: &Client, cmd: Route) -> anyhow::Result<()> { +pub async fn route_cmd( + client: &Client, + router_id: Uuid, + cmd: Route, +) -> anyhow::Result<()> { match cmd { - Route::List { family } => route_list(client, family).await, - Route::Get { cidr } => route_get(client, cidr).await, + Route::List { family } => route_list(client, router_id, family).await, + Route::Get { cidr } => route_get(client, router_id, cidr).await, Route::Add { cidr, link_path, gw, vlan_id } => { - route_add(client, cidr, link_path, gw, vlan_id).await + route_add(client, router_id, cidr, link_path, gw, vlan_id).await } Route::Del { cidr, link_path, gw } => { - route_del(client, cidr, link_path, gw).await + route_del(client, router_id, cidr, link_path, gw).await } } } diff --git a/swadm/src/router.rs b/swadm/src/router.rs new file mode 100644 index 00000000..3bc179dd --- /dev/null +++ b/swadm/src/router.rs @@ -0,0 +1,77 @@ +// 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::collections::BTreeMap; +use std::net::Ipv6Addr; + +use anyhow::Context; +use clap::Subcommand; +use uuid::Uuid; + +use dpd_client::Client; + +#[derive(Debug, Subcommand)] +/// Manage routers +pub enum Router { + /// List all routers and their endpoints + #[clap(visible_alias = "ls")] + List, + /// Show a router's endpoint + Get { + /// Router uuid + router_id: Uuid, + }, + /// Create a router + Create { + /// Router uuid + router_id: Uuid, + /// Endpoint address + #[clap(long)] + endpoint: Ipv6Addr, + }, + /// Delete a router along with its endpoint and routes + Delete { + /// Router uuid + router_id: Uuid, + }, +} + +pub async fn router_cmd(client: &Client, cmd: Router) -> anyhow::Result<()> { + match cmd { + Router::List => { + let routers = client + .router_list() + .await + .context("failed to list routers")? + .into_inner(); + let routers: BTreeMap = + routers.into_iter().collect(); + for (router_id, endpoint) in routers { + println!("{router_id} {endpoint}"); + } + Ok(()) + } + Router::Get { router_id } => { + let endpoint = client + .router_get(&router_id) + .await + .context("failed to get router")? + .into_inner(); + println!("{endpoint}"); + Ok(()) + } + Router::Create { router_id, endpoint } => client + .router_create(&router_id, &endpoint) + .await + .context("failed to create router") + .map(|_| ()), + Router::Delete { router_id } => client + .router_delete(&router_id) + .await + .context("failed to delete router") + .map(|_| ()), + } +} From 06bb86e5c5cc2d5d3f230111d048601a244cf3e5 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Sat, 17 Oct 2026 11:21:27 -0300 Subject: [PATCH 02/10] collapse back to one action --- asic/src/softnpu/table.rs | 9 +++++---- dpd/p4/sidecar.p4 | 10 ++-------- dpd/src/table/port_ip.rs | 15 ++++++++------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/asic/src/softnpu/table.rs b/asic/src/softnpu/table.rs index 44157b4e..5e04aee2 100644 --- a/asic/src/softnpu/table.rs +++ b/asic/src/softnpu/table.rs @@ -119,14 +119,15 @@ impl TableOps for Table { action_data.action.as_str(), ) { // TODO: implement mappings for natv6 actions - (TableType::PortAddrIpv4, "claimv4") => ("local", Vec::new()), - (TableType::PortAddrIpv6, "claimv6") => ("local", Vec::new()), - (TableType::PortAddrIpv6, "claimv6_rid") => { + // sidecar-lite's local action carries the routing table id; + // IPv4 switch addresses belong to the default table. + (TableType::PortAddrIpv4, "claimv4") => ("local", vec![0]), + (TableType::PortAddrIpv6, "claimv6") => { let table_id = u8::try_from( &action_data.arg_by_name("routing_table_id")?.value, ) .map_err(|e| AsicError::InvalidArg(e.into()))?; - ("local_rid", vec![table_id]) + ("local", vec![table_id]) } (TableType::RouteIdxIpv4, "index") => { let mut params = Vec::new(); diff --git a/dpd/p4/sidecar.p4 b/dpd/p4/sidecar.p4 index 7bec7b25..91e685f9 100644 --- a/dpd/p4/sidecar.p4 +++ b/dpd/p4/sidecar.p4 @@ -132,18 +132,12 @@ control Filter( ipv4_ctr.count(); } - action claimv6() { - meta.is_switch_address = true; - ipv6_ctr.count(); - } - - action claimv6_rid(bit<8> routing_table_id) { + action claimv6(bit<8> routing_table_id) { meta.is_switch_address = true; meta.routing_table_id = routing_table_id; ipv6_ctr.count(); } - // Table of the IPv4 addresses assigned to ports on the switch. table switch_ipv4_addr { key = { @@ -162,7 +156,7 @@ control Filter( hdr.ipv6.dst_addr : exact; ig_intr_md.ingress_port : ternary; } - actions = { claimv6; claimv6_rid; dropv6; } + actions = { claimv6; dropv6; } const size = SWITCH_IPV6_ADDRS_SIZE; counters = ipv6_ctr; diff --git a/dpd/src/table/port_ip.rs b/dpd/src/table/port_ip.rs index 1ee033dc..f87b1838 100644 --- a/dpd/src/table/port_ip.rs +++ b/dpd/src/table/port_ip.rs @@ -46,9 +46,7 @@ enum ActionV4 { #[derive(ActionParse)] enum ActionV6 { #[action_xlate(name = "claimv6")] - ClaimIpv6, - #[action_xlate(name = "claimv6_rid")] - ClaimIpv6Rid { routing_table_id: u8 }, + ClaimIpv6 { routing_table_id: u8 }, #[action_xlate(name = "dropv6")] DropIpv6, } @@ -132,8 +130,7 @@ pub fn loopback_ipv6_add( in_port: MatchMask { val: 0u16.into(), mask: 0u16.into() }, }; - let action = - ActionV6::ClaimIpv6Rid { routing_table_id: u8::from(table_id) }; + let action = ActionV6::ClaimIpv6 { routing_table_id: table_id.into() }; s.table_entry_add(TableType::PortAddrIpv6, &claim_key, &action) .map(|_| { @@ -268,7 +265,9 @@ fn ipv6_add_work(s: &Switch, port: u16, ipv6: Ipv6Addr) -> DpdResult<()> { s.table_entry_add( TableType::PortAddrIpv6, &claim_key, - &ActionV6::ClaimIpv6, + &ActionV6::ClaimIpv6 { + routing_table_id: RoutingTableId::default().into(), + }, )?; s.table_entry_add(TableType::PortAddrIpv6, &drop_key, &ActionV6::DropIpv6) .inspect_err(|_| { @@ -308,7 +307,9 @@ fn ipv6_delete_work(s: &Switch, port: u16, ipv6: Ipv6Addr) -> DpdResult<()> { s.table_entry_add( TableType::PortAddrIpv6, &claim_key, - &ActionV6::ClaimIpv6, + &ActionV6::ClaimIpv6 { + routing_table_id: RoutingTableId::default().into(), + }, ) }, ); From 2bc426bc09065641022d1b7dbc0d438e071ca6a8 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Sat, 17 Oct 2026 16:17:06 -0300 Subject: [PATCH 03/10] fix delete_route_locked order and improve testing --- dpd/src/route.rs | 120 ++++++++++++++++++++++++++--------------------- 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/dpd/src/route.rs b/dpd/src/route.rs index 9ad27276..42305ee0 100644 --- a/dpd/src/route.rs +++ b/dpd/src/route.rs @@ -1143,30 +1143,29 @@ pub async fn get_route_ipv6( } } -// Remove a route from the switch, then forget it. If any switch write fails, -// the route is kept so the delete can be retried; entries already removed by -// an earlier attempt count as removed. +// Remove a route from the dataplane, then forget it. +// +// The in-core entry is kept for a retry only if deleting the on-chip +// `route_index` fails, since that is what takes the route off the +// dataplane. An index that is already missing counts as deleted. If +// `cleanup_route` then fails to clear a target, the route's slots are +// leaked rather than freed. fn delete_route_locked( ops: &dyn RouteTableOps, route_data: &mut RouteData, key: RouteKey, ) -> DpdResult<()> { - let already_gone = |r: DpdResult<()>| match r { - Err(DpdError::Switch(AsicError::Missing(_))) => Ok(()), - r => r, - }; let entry = route_data .get(key) .cloned() .ok_or(DpdError::Missing("no such route".into()))?; - already_gone(ops.delete_index(key))?; - for idx in 0..entry.targets.len() as u16 { - already_gone(ops.delete_target(key.subnet, entry.index + idx))?; + match ops.delete_index(key) { + Ok(()) | Err(DpdError::Switch(AsicError::Missing(_))) => {} + Err(e) => return Err(e), } route_data.remove(key); - route_data.freemap_mut(entry.is_ipv4).free(entry.index, entry.slots as u16); - Ok(()) + cleanup_route(ops, route_data, key, entry) } // Delete a route and all of its targets @@ -1997,18 +1996,25 @@ mod tests { let (ops, mut rd) = make_test_ctx(); let doomed = RoutingTableId::from(1); let other = RoutingTableId::from(2); - let v4: Ipv4Net = "192.168.1.0/24".parse().unwrap(); - let v6: Ipv6Net = "fd00:1::/64".parse().unwrap(); - let gw4: IpAddr = "10.0.0.1".parse().unwrap(); - let gw6: IpAddr = "fd00::1".parse().unwrap(); + let (f4, f6) = (fixtures_v4(), fixtures_v6()); for table_id in [RoutingTableId::default(), doomed, other] { - install_victim(&ops, &mut rd, RouteKey::new(table_id, v4), [gw4]); - install_victim(&ops, &mut rd, RouteKey::new(table_id, v6), [gw6]); + install_victim( + &ops, + &mut rd, + RouteKey::new(table_id, f4.victim.subnet), + [f4.targets[0]], + ); + install_victim( + &ops, + &mut rd, + RouteKey::new(table_id, f6.victim.subnet), + [f6.targets[0]], + ); } rd.delete_table(&ops, doomed).unwrap(); assert!(!rd.tables.contains_key(&doomed)); - for subnet in [IpNet::from(v4), IpNet::from(v6)] { + for subnet in [f4.victim.subnet, f6.victim.subnet] { let gone = RouteKey::new(doomed, subnet); assert!(!ops.indexes.borrow().contains(&gone)); for table_id in [RoutingTableId::default(), other] { @@ -2022,27 +2028,14 @@ mod tests { rd.delete_table(&ops, doomed).unwrap(); } - fn install_route( - rd: &mut RouteData, - ops: &TestOps, - table_id: RoutingTableId, - ) { - let v4: Ipv4Net = "192.168.1.0/24".parse().unwrap(); - let gw: IpAddr = "10.0.0.1".parse().unwrap(); - let gw2: IpAddr = "10.0.0.2".parse().unwrap(); - install_victim(ops, rd, RouteKey::new(table_id, v4), [gw, gw2]); - } - /// A failed index delete keeps the route, and a retry removes it. #[test] fn delete_table_keeps_route_when_index_delete_fails() { let (ops, mut rd) = make_test_ctx(); let table_id = RoutingTableId::from(1); - install_route(&mut rd, &ops, table_id); - let key = RouteKey::new( - table_id, - "192.168.1.0/24".parse::().unwrap(), - ); + let f = fixtures_v4(); + let key = RouteKey::new(table_id, f.victim.subnet); + install_victim(&ops, &mut rd, key, [f.targets[0]]); ops.arm(Op::DelIndex, 0); assert!(rd.delete_table(&ops, table_id).is_err()); @@ -2054,33 +2047,52 @@ mod tests { assert!(ops.targets.borrow().is_empty()); } - /// The first attempt removes the index and one target before failing, - /// so the retry only succeeds if it treats those as already removed. + /// A failed target delete still forgets the route, and the slot whose + /// target is still installed is not handed out again. #[test] - fn delete_table_keeps_route_when_target_delete_fails() { + fn delete_table_forgets_route_when_target_delete_fails() { let (ops, mut rd) = make_test_ctx(); let table_id = RoutingTableId::from(1); - install_route(&mut rd, &ops, table_id); - let key = RouteKey::new( - table_id, - "192.168.1.0/24".parse::().unwrap(), - ); + let f = fixtures_v4(); + let key = RouteKey::new(table_id, f.victim.subnet); + install_victim(&ops, &mut rd, key, [f.targets[0], f.targets[1]]); let entry = rd.get(key).unwrap().clone(); + // Fail the second target delete ops.arm(Op::DelTarget, 1); - assert!(rd.delete_table(&ops, table_id).is_err()); - assert_eq!(rd.get(key), Some(&entry), "route must be kept for a retry"); + rd.delete_table(&ops, table_id).expect("delete must succeed"); + assert!(!rd.tables.contains_key(&table_id)); + assert!(rd.get(key).is_none()); assert!(!ops.indexes.borrow().contains(&key)); - assert_eq!(ops.targets.borrow().len(), 1); - rd.delete_table(&ops, table_id).expect("retry must succeed"); - assert!(!rd.tables.contains_key(&table_id)); - assert!(ops.targets.borrow().is_empty()); - // The slots are free again only after the retry succeeded. - assert_eq!( - rd.freemap_mut(true).alloc(entry.slots).unwrap(), - entry.index - ); + // The second target's delete failed, so its slot must not be handed + // out again. + let still_installed = entry.index + 1; + let idx = rd.freemap_mut(true).alloc(entry.slots).unwrap(); + assert!(!(idx..idx + entry.slots as u16).contains(&still_installed)); + } + + /// After a delete whose target cleanup fails, the route is gone, so adding + /// it again installs a fresh index. + #[test] + fn readd_after_failed_target_delete_installs_route() { + let (ops, mut rd) = make_test_ctx(); + let f = fixtures_v4(); + install_victim(&ops, &mut rd, f.victim, [f.targets[0]]); + + ops.arm(Op::DelTarget, 0); + let _ = delete_route_locked(&ops, &mut rd, f.victim); + + add_route_locked( + &ops, + &mut rd, + f.victim, + make_route(f.targets[0]), + FAKE_ASIC_PORT, + ) + .expect("re-add must succeed"); + assert!(rd.get(f.victim).is_some()); + assert!(ops.indexes.borrow().contains(&f.victim)); } /// A same-length, non-subset replace (one target swapped for another) From 8bb71374f362839a02b505d69e94c198472a015d Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Sat, 17 Oct 2026 16:44:52 -0300 Subject: [PATCH 04/10] improve router endpoint resilience when delete fails --- dpd/src/router.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/dpd/src/router.rs b/dpd/src/router.rs index b4b163f7..afa67d6c 100644 --- a/dpd/src/router.rs +++ b/dpd/src/router.rs @@ -139,13 +139,27 @@ impl Routers { } if let Some(r) = self.0.get(&router) { if r.endpoint == endpoint { - return Ok(()); + // A failed delete may have removed the endpoint; reinstall it. + let entry = + Ipv6Entry { tag: router.to_string(), addr: endpoint }; + return match loopback::add_loopback_ipv6( + switch, &entry, r.table_id, + ) { + Ok(()) | Err(DpdError::Exists(_)) => Ok(()), + Err(e) => Err(e), + }; } return Err(DpdError::Exists(format!( "router {router} already has endpoint {}", r.endpoint ))); } + // An endpoint needs to map to a single router. + if self.0.values().any(|r| r.endpoint == endpoint) { + return Err(DpdError::Exists(format!( + "{endpoint} is already taken" + ))); + } let table_id = (1..=u8::MAX) .map(RoutingTableId::from) .find(|table_id| self.0.values().all(|r| r.table_id != *table_id)) @@ -163,8 +177,8 @@ impl Routers { /// /// The router is forgotten only once everything under it has been removed, /// so its table is never given to another router while entries for it -/// remain. If a removal fails, the router still exists and the delete can be -/// retried. +/// remain. If a removal fails, the router still exists; the delete can be +/// retried or a PUT with the same endpoint can restore it. pub fn delete( switch: &Switch, route_data: &mut RouteData, From 551068a2da20aaa3d842f149180befe473f650f5 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Sat, 17 Oct 2026 20:47:44 -0300 Subject: [PATCH 05/10] return /route/ for underlay and default router routes --- dpd-api/src/lib.rs | 99 ++-- .../integration_tests/attached_subnet.rs | 2 +- dpd-client/tests/integration_tests/common.rs | 12 +- dpd-client/tests/integration_tests/nat.rs | 2 +- .../tests/integration_tests/route_ipv4.rs | 113 ++--- .../tests/integration_tests/route_ipv6.rs | 32 +- dpd-client/tests/integration_tests/router.rs | 80 +-- .../tests/integration_tests/table_tests.rs | 12 +- ...0.0-152d98.json => dpd-14.0.0-c16d01.json} | 475 +++++++++++++++++- openapi/dpd/dpd-latest.json | 2 +- swadm/src/route.rs | 20 +- 11 files changed, 641 insertions(+), 208 deletions(-) rename openapi/dpd/{dpd-14.0.0-152d98.json => dpd-14.0.0-c16d01.json} (96%) diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 81c0f0cb..6e81d559 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -192,7 +192,8 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_list", }] async fn route_ipv6_list( rqctx: RequestContext, @@ -203,13 +204,14 @@ pub trait DpdApi { ) -> Result>, HttpError>; /** - * Fetch the configured IPv6 routes, mapping IPv6 CIDR blocks to the switch port - * used for sending out that traffic, and optionally a gateway. + * Fetch the IPv6 routes configured on the default routing table, mapping + * IPv6 CIDR blocks to the switch port used for sending out that traffic, + * and optionally a gateway. */ #[endpoint { method = GET, path = "/route/ipv6", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_list", }] async fn route_ipv6_list_v1( @@ -229,7 +231,8 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router/{router_id}/route/ipv6/{cidr}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_get", }] async fn route_ipv6_get( rqctx: RequestContext, @@ -237,12 +240,13 @@ pub trait DpdApi { ) -> Result>, HttpError>; /** - * Get a single IPv6 route, by its IPv6 CIDR block. + * Get a single IPv6 route on the default routing table, by its IPv6 CIDR + * block. */ #[endpoint { method = GET, path = "/route/ipv6/{cidr}", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_get", }] async fn route_ipv6_get_v1( @@ -262,7 +266,8 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_add", }] async fn route_ipv6_add( rqctx: RequestContext, @@ -271,7 +276,8 @@ pub trait DpdApi { ) -> Result; /** - * Route an IPv6 subnet to a link and a nexthop gateway. + * Route an IPv6 subnet to a link and a nexthop gateway on the default + * routing table. * * This call can be used to create a new single-path route or to add new targets * to a multipath route. @@ -279,7 +285,7 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv6", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_add", }] async fn route_ipv6_add_v1( @@ -300,7 +306,8 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_set", }] async fn route_ipv6_set( rqctx: RequestContext, @@ -309,7 +316,8 @@ pub trait DpdApi { ) -> Result; /** - * Route an IPv6 subnet to a link and a nexthop gateway. + * Route an IPv6 subnet to a link and a nexthop gateway on the default + * routing table. * * This call can be used to create a new single-path route or to replace any * existing routes with a new single-path route. @@ -317,7 +325,7 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv6", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_set", }] async fn route_ipv6_set_v1( @@ -334,7 +342,8 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/router/{router_id}/route/ipv6/{cidr}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_delete", }] async fn route_ipv6_delete( rqctx: RequestContext, @@ -342,12 +351,13 @@ pub trait DpdApi { ) -> Result; /** - * Remove an IPv6 route, by its IPv6 CIDR block. + * Remove an IPv6 route on the default routing table, by its IPv6 CIDR + * block. */ #[endpoint { method = DELETE, path = "/route/ipv6/{cidr}", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_delete", }] async fn route_ipv6_delete_v1( @@ -363,7 +373,8 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv6_delete_target", }] async fn route_ipv6_delete_target( rqctx: RequestContext, @@ -371,12 +382,13 @@ pub trait DpdApi { ) -> Result; /** - * Remove a single target for the given IPv6 subnet + * Remove a single target for the given IPv6 subnet on the default routing + * table */ #[endpoint { method = DELETE, path = "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv6_delete_target", }] async fn route_ipv6_delete_target_v1( @@ -394,7 +406,8 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_list", }] async fn route_ipv4_list( rqctx: RequestContext, @@ -405,13 +418,14 @@ pub trait DpdApi { ) -> Result>, HttpError>; /** - * Fetch the configured IPv4 routes, mapping IPv4 CIDR blocks to the switch port - * used for sending out that traffic, and optionally a gateway. + * Fetch the IPv4 routes configured on the default routing table, mapping + * IPv4 CIDR blocks to the switch port used for sending out that traffic, + * and optionally a gateway. */ #[endpoint { method = GET, path = "/route/ipv4", - versions = VERSION_V4_OVER_V6_ROUTES..VERSION_MULTI_ROUTER, + versions = VERSION_V4_OVER_V6_ROUTES.., operation_id = "route_ipv4_list", }] async fn route_ipv4_list_v4( @@ -455,7 +469,8 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router/{router_id}/route/ipv4/{cidr}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_get", }] async fn route_ipv4_get( rqctx: RequestContext, @@ -463,12 +478,13 @@ pub trait DpdApi { ) -> Result>, HttpError>; /** - * Get the configured route for the given IPv4 subnet. + * Get the configured route for the given IPv4 subnet on the default routing + * table. */ #[endpoint { method = GET, path = "/route/ipv4/{cidr}", - versions = VERSION_V4_OVER_V6_ROUTES..VERSION_MULTI_ROUTER, + versions = VERSION_V4_OVER_V6_ROUTES.., operation_id = "route_ipv4_get", }] async fn route_ipv4_get_v4( @@ -509,7 +525,8 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_add", }] async fn route_ipv4_add( rqctx: RequestContext, @@ -518,7 +535,8 @@ pub trait DpdApi { ) -> Result; /** - * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6). + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the default routing table. * * This call can be used to create a new single-path route or to add new targets * to a multipath route. @@ -526,7 +544,7 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + versions = VERSION_CONSOLIDATED_V4_ROUTES.., operation_id = "route_ipv4_add", }] async fn route_ipv4_add_v6( @@ -587,7 +605,8 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_set", }] async fn route_ipv4_set( rqctx: RequestContext, @@ -596,7 +615,8 @@ pub trait DpdApi { ) -> Result; /** - * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6). + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the default routing table. * * This call can be used to create a new single-path route or to replace any * existing routes with a new single-path route. @@ -604,7 +624,7 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + versions = VERSION_CONSOLIDATED_V4_ROUTES.., operation_id = "route_ipv4_set", }] async fn route_ipv4_set_v6( @@ -661,7 +681,8 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/router/{router_id}/route/ipv4/{cidr}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_delete", }] async fn route_ipv4_delete( rqctx: RequestContext, @@ -669,12 +690,12 @@ pub trait DpdApi { ) -> Result; /** - * Remove all targets for the given subnet + * Remove all targets for the given subnet on the default routing table */ #[endpoint { method = DELETE, path = "/route/ipv4/{cidr}", - versions = ..VERSION_MULTI_ROUTER, + versions = .., operation_id = "route_ipv4_delete", }] async fn route_ipv4_delete_v1( @@ -691,7 +712,8 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/router/{router_id}/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MULTI_ROUTER.., + operation_id = "router_route_ipv4_delete_target", }] async fn route_ipv4_delete_target( rqctx: RequestContext, @@ -699,12 +721,13 @@ pub trait DpdApi { ) -> Result; /** - * Remove a single target for the given IPv4 subnet (IPv4 or IPv6 next hop) + * Remove a single target for the given IPv4 subnet on the default routing + * table (IPv4 or IPv6 next hop) */ #[endpoint { method = DELETE, path = "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_CONSOLIDATED_V4_ROUTES..VERSION_MULTI_ROUTER, + versions = VERSION_CONSOLIDATED_V4_ROUTES.., operation_id = "route_ipv4_delete_target", }] async fn route_ipv4_delete_target_v6( diff --git a/dpd-client/tests/integration_tests/attached_subnet.rs b/dpd-client/tests/integration_tests/attached_subnet.rs index 97d95fd0..19f7ff96 100644 --- a/dpd-client/tests/integration_tests/attached_subnet.rs +++ b/dpd-client/tests/integration_tests/attached_subnet.rs @@ -226,7 +226,7 @@ async fn test_ingress(switch: &Switch, test: &ExternalTest) -> TestResult { // Add the route to the gimlet over the backplane, and populate the neighbor // table with its mac address. - switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await.unwrap(); + switch.client.route_ipv6_set(&route).await.unwrap(); common::add_neighbor_ipv6(switch, &test.gimlet_ip, gimlet_mac).await?; // Set up an external subnet owned by an instance on the test gimlet diff --git a/dpd-client/tests/integration_tests/common.rs b/dpd-client/tests/integration_tests/common.rs index a18f166b..f982cd72 100644 --- a/dpd-client/tests/integration_tests/common.rs +++ b/dpd-client/tests/integration_tests/common.rs @@ -1108,13 +1108,13 @@ async fn set_route_ipv6_common( }; switch .client - .route_ipv6_set(&DEFAULT_ROUTER, &route) + .route_ipv6_set(&route) .await .expect("Failed to add IPv6 route entry"); let route = switch .client - .route_ipv6_get(&DEFAULT_ROUTER, &cidr) + .route_ipv6_get(&cidr) .await .expect("Failed to get just-added IPv6 route entry") .into_inner(); @@ -1202,13 +1202,13 @@ async fn set_route_ipv4_common( }; switch .client - .route_ipv4_set(&DEFAULT_ROUTER, &route) + .route_ipv4_set(&route) .await .expect("Failed to add IPv4 route entry"); let route = switch .client - .route_ipv4_get(&DEFAULT_ROUTER, &cidr) + .route_ipv4_get(&cidr) .await .expect("failed to get just-added IPv4 route entry") .into_inner(); @@ -1257,13 +1257,13 @@ async fn set_route_ipv4_over_ipv6_common( }; switch .client - .route_ipv4_set(&DEFAULT_ROUTER, &route) + .route_ipv4_set(&route) .await .expect("Failed to add IPv4 route entry"); let route = switch .client - .route_ipv4_get(&DEFAULT_ROUTER, &cidr) + .route_ipv4_get(&cidr) .await .expect("failed to get just-added IPv4 route entry") .into_inner(); diff --git a/dpd-client/tests/integration_tests/nat.rs b/dpd-client/tests/integration_tests/nat.rs index a64859bc..9d7372a1 100644 --- a/dpd-client/tests/integration_tests/nat.rs +++ b/dpd-client/tests/integration_tests/nat.rs @@ -338,7 +338,7 @@ async fn test_nat_ingress(switch: &Switch, test: &NatTest) -> TestResult { }, replace: false, }; - switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await.unwrap(); + switch.client.route_ipv6_set(&route).await.unwrap(); common::add_neighbor_ipv6(switch, &test.gimlet_ip, gimlet_mac).await?; let load = vec![0xaau8, 0xbb, 0xcc, 0xdd, 0xee]; diff --git a/dpd-client/tests/integration_tests/route_ipv4.rs b/dpd-client/tests/integration_tests/route_ipv4.rs index fd13856e..37cd8fcc 100644 --- a/dpd-client/tests/integration_tests/route_ipv4.rs +++ b/dpd-client/tests/integration_tests/route_ipv4.rs @@ -65,7 +65,7 @@ async fn add_route( let route = router.build_route(switch); let route_add = build_route_update(cidr, &route, false); - client.route_ipv4_add(&DEFAULT_ROUTER, &route_add).await?; + client.route_ipv4_add(&route_add).await?; Ok(()) } @@ -88,7 +88,7 @@ async fn validate_routes( expected: &[types::Ipv4Route], ) -> TestResult { if expected.is_empty() { - match client.route_ipv4_get(&DEFAULT_ROUTER, cidr).await { + match client.route_ipv4_get(&cidr).await { Ok(f) => { Err(anyhow!("found {} targets - expected no route", f.len())) } @@ -96,7 +96,7 @@ async fn validate_routes( } } else { // Verify that the set of routes on the switch match those we just set - let found = client.route_ipv4_get(&DEFAULT_ROUTER, cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(found.len(), expected.len()); for target in expected { assert!( @@ -146,7 +146,7 @@ async fn test_deleted_route_ipv4_impl(switch: &Switch) -> TestResult { // Delete that route entry, and check that we cannot send out the egress // port. let cidr = "10.10.10.0/24".parse().unwrap(); - switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await.unwrap(); + switch.client.route_ipv4_delete(&cidr).await.unwrap(); let to_send = common::gen_udp_packet( Endpoint::parse("e0:d5:5e:67:89:ab", "10.10.10.10", 3333).unwrap(), @@ -348,7 +348,7 @@ async fn test_reset() -> TestResult { let limit = std::num::NonZeroU32::new(32).unwrap(); let routes = switch .client - .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv4_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -357,7 +357,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("failed").await.unwrap(); let routes = switch .client - .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv4_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -366,7 +366,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("test").await.unwrap(); let routes = switch .client - .route_ipv4_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv4_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -390,18 +390,15 @@ async fn test_create_and_set_semantics_v4() -> TestResult { let route_set_33 = build_route_update(cidr, &route_33, false); // Setting a new route should work - client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_47).await?; + client.route_ipv4_set(&route_set_47).await?; // Attempting to replace the route with "replace = false" should fail - client - .route_ipv4_set(&DEFAULT_ROUTER, &route_set_33) - .await - .expect_err("expected conflict"); + client.route_ipv4_set(&route_set_33).await.expect_err("expected conflict"); // Re-setting the existing route should succeed - client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_47).await?; + client.route_ipv4_set(&route_set_47).await?; // Attempting to replace the route with "replace = true" should succeed let route_set_33 = build_route_update(cidr, &route_33, true); - client.route_ipv4_set(&DEFAULT_ROUTER, &route_set_33).await?; + client.route_ipv4_set(&route_set_33).await?; // Verify that the route was replaced correctly validate_routes(client, &cidr, &[route_33]).await } @@ -467,7 +464,6 @@ async fn delete_ipv4_route_target( let tgt_ip: std::net::IpAddr = target.tgt_ip.into(); client .route_ipv4_delete_target( - &DEFAULT_ROUTER, cidr, &target.port_id, &target.link_id, @@ -521,12 +517,7 @@ async fn add_ipv4_route_target( cidr: Ipv4Net, route: &types::Ipv4Route, ) -> Result<(), dpd_client::Error> { - client - .route_ipv4_add( - &DEFAULT_ROUTER, - &build_route_update(cidr, route, false), - ) - .await?; + client.route_ipv4_add(&build_route_update(cidr, route, false)).await?; Ok(()) } @@ -536,14 +527,11 @@ async fn add_ipv4_over_ipv6_route_target( route: &types::Ipv6Route, ) -> Result<(), dpd_client::Error> { client - .route_ipv4_add( - &DEFAULT_ROUTER, - &types::Ipv4RouteUpdate { - cidr, - target: types::RouteTarget::V6(route.clone()), - replace: false, - }, - ) + .route_ipv4_add(&types::Ipv4RouteUpdate { + cidr, + target: types::RouteTarget::V6(route.clone()), + replace: false, + }) .await?; Ok(()) } @@ -776,7 +764,7 @@ async fn test_add_target_succeeds_when_table_fragmented_v4_over_v6() add_ipv4_over_ipv6_route_target(client, victim_cidr, &new_target).await?; victim_routes.push(new_target); - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &victim_cidr).await?; + let found = client.route_ipv4_get(&victim_cidr).await?; assert_eq!(found.len(), victim_routes.len()); for route in &victim_routes { assert!(found.contains(&types::Route::V6(route.clone()))); @@ -942,17 +930,11 @@ async fn test_v4_over_v6() -> TestResult { let tgt_ip: std::net::IpAddr = gw.into(); switch .client - .route_ipv4_delete_target( - &DEFAULT_ROUTER, - &cidr, - &port_id, - &link_id, - &tgt_ip, - ) + .route_ipv4_delete_target(&cidr, &port_id, &link_id, &tgt_ip) .await?; switch .client - .route_ipv4_get(&DEFAULT_ROUTER, &cidr) + .route_ipv4_get(&cidr) .await .expect_err("route should be gone after deleting its only target"); @@ -985,10 +967,10 @@ async fn test_v4_over_v6() -> TestResult { switch.packet_test(vec![send], vec![expected])?; // Delete the entire prefix and verify it is gone. - switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + switch.client.route_ipv4_delete(&cidr).await?; switch .client - .route_ipv4_get(&DEFAULT_ROUTER, &cidr) + .route_ipv4_get(&cidr) .await .expect_err("route should be gone after deleting the prefix"); @@ -1057,15 +1039,16 @@ async fn test_multipath_mixed_delete() -> TestResult { types::RouteTarget::V6(v6_d.clone()), ] { client - .route_ipv4_add( - &DEFAULT_ROUTER, - &types::Ipv4RouteUpdate { cidr, target, replace: false }, - ) + .route_ipv4_add(&types::Ipv4RouteUpdate { + cidr, + target, + replace: false, + }) .await?; } // Verify all four targets are present. - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(found.len(), 4); assert!(found.contains(&types::Route::V4(v4_a.clone()))); assert!(found.contains(&types::Route::V6(v6_b.clone()))); @@ -1075,15 +1058,9 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v6 target B — the two v4 targets and v6 target D should remain. let tgt_ip: IpAddr = v6_b.tgt_ip.into(); client - .route_ipv4_delete_target( - &DEFAULT_ROUTER, - &cidr, - &v6_b.port_id, - &v6_b.link_id, - &tgt_ip, - ) + .route_ipv4_delete_target(&cidr, &v6_b.port_id, &v6_b.link_id, &tgt_ip) .await?; - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(found.len(), 3); assert!(found.contains(&types::Route::V4(v4_a.clone()))); assert!(found.contains(&types::Route::V4(v4_c.clone()))); @@ -1092,15 +1069,9 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v4 target A — v4 target C and v6 target D should remain. let tgt_ip: IpAddr = v4_a.tgt_ip.into(); client - .route_ipv4_delete_target( - &DEFAULT_ROUTER, - &cidr, - &v4_a.port_id, - &v4_a.link_id, - &tgt_ip, - ) + .route_ipv4_delete_target(&cidr, &v4_a.port_id, &v4_a.link_id, &tgt_ip) .await?; - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(found.len(), 2); assert!(found.contains(&types::Route::V4(v4_c.clone()))); assert!(found.contains(&types::Route::V6(v6_d.clone()))); @@ -1108,31 +1079,19 @@ async fn test_multipath_mixed_delete() -> TestResult { // Delete v6 target D — only v4 target C should remain. let tgt_ip: IpAddr = v6_d.tgt_ip.into(); client - .route_ipv4_delete_target( - &DEFAULT_ROUTER, - &cidr, - &v6_d.port_id, - &v6_d.link_id, - &tgt_ip, - ) + .route_ipv4_delete_target(&cidr, &v6_d.port_id, &v6_d.link_id, &tgt_ip) .await?; - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(found.len(), 1); assert!(found.contains(&types::Route::V4(v4_c.clone()))); // Delete the last v4 target C — the route should be completely gone. let tgt_ip: IpAddr = v4_c.tgt_ip.into(); client - .route_ipv4_delete_target( - &DEFAULT_ROUTER, - &cidr, - &v4_c.port_id, - &v4_c.link_id, - &tgt_ip, - ) + .route_ipv4_delete_target(&cidr, &v4_c.port_id, &v4_c.link_id, &tgt_ip) .await?; client - .route_ipv4_get(&DEFAULT_ROUTER, &cidr) + .route_ipv4_get(&cidr) .await .expect_err("route should be gone after deleting all targets"); diff --git a/dpd-client/tests/integration_tests/route_ipv6.rs b/dpd-client/tests/integration_tests/route_ipv6.rs index dfe74605..dbdf6917 100644 --- a/dpd-client/tests/integration_tests/route_ipv6.rs +++ b/dpd-client/tests/integration_tests/route_ipv6.rs @@ -67,7 +67,7 @@ async fn add_route( let route = router.build_route(switch); let route_add = build_route_add(cidr, &route); - client.route_ipv6_add(&DEFAULT_ROUTER, &route_add).await?; + client.route_ipv6_add(&route_add).await?; Ok(()) } @@ -78,14 +78,14 @@ async fn validate_routes( expected: &[types::Ipv6Route], ) -> TestResult { if expected.is_empty() { - match client.route_ipv6_get(&DEFAULT_ROUTER, cidr).await { + match client.route_ipv6_get(cidr).await { Ok(f) => { Err(anyhow!("found {} targets - expected no route", f.len())) } Err(_) => Ok(()), } } else { - let found = client.route_ipv6_get(&DEFAULT_ROUTER, cidr).await?; + let found = client.route_ipv6_get(cidr).await?; assert_eq!(found.len(), expected.len()); for target in expected { assert!(found.iter().any(|t| t == target)); @@ -187,7 +187,7 @@ async fn test_deleted_unicast_impl(switch: &Switch) -> TestResult { test_unicast_impl(switch, None).await?; let cidr = "fd00:1122:3344:0100::/56".parse().unwrap(); - switch.client.route_ipv6_delete(&DEFAULT_ROUTER, &cidr).await.unwrap(); + switch.client.route_ipv6_delete(&cidr).await.unwrap(); let to_send = common::gen_udp_packet( Endpoint::parse("e0:d5:5e:67:89:ab", "fd00:1122:7788:0101::4", 3333) @@ -562,7 +562,7 @@ async fn test_reset() -> TestResult { let limit = std::num::NonZeroU32::new(32).unwrap(); let routes = switch .client - .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv6_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -571,7 +571,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("failed").await.unwrap(); let routes = switch .client - .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv6_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -580,7 +580,7 @@ async fn test_reset() -> TestResult { switch.client.reset_all_tagged("test").await.unwrap(); let routes = switch .client - .route_ipv6_list(&DEFAULT_ROUTER, Some(limit), None) + .route_ipv6_list(Some(limit), None) .await .unwrap() .into_inner(); @@ -617,20 +617,17 @@ async fn test_create_and_set_semantics_v6() -> TestResult { }; // Setting a new route should work - client.route_ipv6_set(&DEFAULT_ROUTER, &route47).await?; + client.route_ipv6_set(&route47).await?; // Attempting to replace the route with "replace = false" should fail - client - .route_ipv6_set(&DEFAULT_ROUTER, &route33) - .await - .expect_err("expected conflict"); + client.route_ipv6_set(&route33).await.expect_err("expected conflict"); // Re-setting the existing route should succeed - client.route_ipv6_set(&DEFAULT_ROUTER, &route47).await?; + client.route_ipv6_set(&route47).await?; // Attempting to replace the route with "replace = true" should success route33.replace = true; - client.route_ipv6_set(&DEFAULT_ROUTER, &route33).await?; + client.route_ipv6_set(&route33).await?; // Verify that the route was replaced correctly - let rt = client.route_ipv6_get(&DEFAULT_ROUTER, &cidr).await?; + let rt = client.route_ipv6_get(&cidr).await?; assert_eq!(rt.len(), 1); assert_eq!(rt[0].tgt_ip, target33.tgt_ip); @@ -673,9 +670,7 @@ async fn add_ipv6_route_target( cidr: Ipv6Net, route: &types::Ipv6Route, ) -> Result<(), dpd_client::Error> { - client - .route_ipv6_add(&DEFAULT_ROUTER, &build_route_add(cidr, route)) - .await?; + client.route_ipv6_add(&build_route_add(cidr, route)).await?; Ok(()) } @@ -686,7 +681,6 @@ async fn delete_ipv6_route_target( ) -> TestResult { client .route_ipv6_delete_target( - &DEFAULT_ROUTER, cidr, &target.port_id, &target.link_id, diff --git a/dpd-client/tests/integration_tests/router.rs b/dpd-client/tests/integration_tests/router.rs index 46664c74..8d5aaf59 100644 --- a/dpd-client/tests/integration_tests/router.rs +++ b/dpd-client/tests/integration_tests/router.rs @@ -59,17 +59,17 @@ async fn test_same_prefix_two_routers() -> TestResult { let router_target = ipv4_route(switch, 14, "10.10.14.1"); client.router_create(&router, &ipv6("fd00:201::1")).await?; + client.route_ipv4_add(&route_update(cidr, &default_target)).await?; client - .route_ipv4_add(&DEFAULT_ROUTER, &route_update(cidr, &default_target)) + .router_route_ipv4_add(&router, &route_update(cidr, &router_target)) .await?; - client.route_ipv4_add(&router, &route_update(cidr, &router_target)).await?; - let found = client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?; + let found = client.route_ipv4_get(&cidr).await?; assert_eq!(*found, vec![types::Route::V4(default_target)]); - let found = client.route_ipv4_get(&router, &cidr).await?; + let found = client.router_route_ipv4_get(&router, &cidr).await?; assert_eq!(*found, vec![types::Route::V4(router_target)]); - client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.route_ipv4_delete(&cidr).await?; client.router_delete(&router).await?; Ok(()) } @@ -87,22 +87,21 @@ async fn test_router_delete() -> TestResult { let endpoint = ipv6("fd00:202::2"); client.router_create(&router, &endpoint).await?; - for r in [DEFAULT_ROUTER, router] { - client.route_ipv4_add(&r, &route_update(cidr, &target)).await?; - } + client.route_ipv4_add(&route_update(cidr, &target)).await?; + client.router_route_ipv4_add(&router, &route_update(cidr, &target)).await?; client.router_delete(&router).await?; assert!(!client.router_list().await?.contains_key(&router.to_string())); - let err = client.route_ipv4_get(&router, &cidr).await.unwrap_err(); + let err = client.router_route_ipv4_get(&router, &cidr).await.unwrap_err(); assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); - assert_eq!(client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?.len(), 1); + assert_eq!(client.route_ipv4_get(&cidr).await?.len(), 1); // The deleted router's endpoint is free to be claimed again. let again = Uuid::new_v4(); client.router_create(&again, &endpoint).await?; - client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.route_ipv4_delete(&cidr).await?; client.router_delete(&again).await?; Ok(()) } @@ -119,14 +118,14 @@ async fn test_uncreated_router() -> TestResult { let target = ipv4_route(switch, 10, "10.10.10.1"); let statuses = [ - client.route_ipv4_list(&router, None, None).await.map(|_| ()), - client.route_ipv4_get(&router, &cidr).await.map(|_| ()), + client.router_route_ipv4_list(&router, None, None).await.map(|_| ()), + client.router_route_ipv4_get(&router, &cidr).await.map(|_| ()), client - .route_ipv4_add(&router, &route_update(cidr, &target)) + .router_route_ipv4_add(&router, &route_update(cidr, &target)) .await .map(|_| ()), - client.route_ipv4_delete(&router, &cidr).await.map(|_| ()), - client.route_ipv6_list(&router, None, None).await.map(|_| ()), + client.router_route_ipv4_delete(&router, &cidr).await.map(|_| ()), + client.router_route_ipv6_list(&router, None, None).await.map(|_| ()), client.router_get(&router).await.map(|_| ()), ] .map(|r| r.unwrap_err().status()); @@ -239,9 +238,7 @@ async fn test_default_router_is_not_a_router() -> TestResult { let cidr: Ipv4Net = "10.206.0.0/24".parse().unwrap(); let target = ipv4_route(switch, 10, "10.10.10.1"); - client - .route_ipv4_add(&DEFAULT_ROUTER, &route_update(cidr, &target)) - .await?; + client.route_ipv4_add(&route_update(cidr, &target)).await?; let err = client.router_get(&DEFAULT_ROUTER).await.unwrap_err(); assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); @@ -253,13 +250,13 @@ async fn test_default_router_is_not_a_router() -> TestResult { let err = client.router_delete(&DEFAULT_ROUTER).await.unwrap_err(); assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); assert!(client.router_list().await?.is_empty()); - assert_eq!(client.route_ipv4_get(&DEFAULT_ROUTER, &cidr).await?.len(), 1); + assert_eq!(client.route_ipv4_get(&cidr).await?.len(), 1); - client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.route_ipv4_delete(&cidr).await?; Ok(()) } -/// A route added by an API v13 client uses the default router. +/// v13 and v14 clients reach the same table through `/route/*`. #[tokio::test] #[ignore] async fn test_legacy_route_uses_default_router() -> TestResult { @@ -276,11 +273,11 @@ async fn test_legacy_route_uses_default_router() -> TestResult { .await?; assert_eq!(resp.status(), StatusCode::NO_CONTENT); - let routes = client.route_ipv4_list(&DEFAULT_ROUTER, None, None).await?; + let routes = client.route_ipv4_list(None, None).await?; let found = routes.items.iter().find(|r| r.cidr == cidr).unwrap(); assert_eq!(found.targets, vec![types::Route::V4(target)]); - client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await?; + client.route_ipv4_delete(&cidr).await?; Ok(()) } @@ -364,15 +361,19 @@ async fn test_recreated_router_has_no_routes() -> TestResult { // table. let first = Uuid::new_v4(); client.router_create(&first, &endpoint).await?; - client.route_ipv4_add(&first, &route_update(cidr, &target)).await?; + client.router_route_ipv4_add(&first, &route_update(cidr, &target)).await?; client.router_delete(&first).await?; let second = Uuid::new_v4(); client.router_create(&second, &endpoint).await?; assert!( - client.route_ipv4_list(&second, None, None).await?.items.is_empty() + client + .router_route_ipv4_list(&second, None, None) + .await? + .items + .is_empty() ); - let err = client.route_ipv4_get(&second, &cidr).await.unwrap_err(); + let err = client.router_route_ipv4_get(&second, &cidr).await.unwrap_err(); assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); client.router_delete(&second).await?; @@ -408,16 +409,19 @@ async fn add_link_addr(switch: &Switch) -> TestResult { /// uplink, as decapsulated packets may only leave through uplinks. async fn add_route( switch: &Switch, - router: Uuid, + router: Option, cidr: &str, port: u16, ) -> TestResult { let gw = format!("10.10.{port}.1"); let target = ipv4_route(switch, port, &gw); - switch - .client - .route_ipv4_add(&router, &route_update(cidr.parse()?, &target)) - .await?; + let update = route_update(cidr.parse()?, &target); + match router { + None => switch.client.route_ipv4_add(&update).await?, + Some(router) => { + switch.client.router_route_ipv4_add(&router, &update).await? + } + }; common::add_arp_ipv4(switch, &gw, GW_MAC.parse()?).await?; switch.set_uplink(PhysPort(port), true).await; Ok(()) @@ -474,9 +478,9 @@ async fn test_endpoint_selects_router() -> TestResult { add_link_addr(switch).await?; let (a, b) = create_a_and_b(switch).await?; let cidr = "10.10.10.0/24"; - add_route(switch, DEFAULT_ROUTER, cidr, 14).await?; - add_route(switch, a, cidr, 15).await?; - add_route(switch, b, cidr, 16).await?; + add_route(switch, None, cidr, 14).await?; + add_route(switch, Some(a), cidr, 15).await?; + add_route(switch, Some(b), cidr, 16).await?; let inner = inner_packet(switch, "10.10.10.32"); for (outer_dst, port) in @@ -497,7 +501,7 @@ async fn test_no_fallback_to_default_router() -> TestResult { let switch = &*get_switch().await; add_link_addr(switch).await?; create_a_and_b(switch).await?; - add_route(switch, DEFAULT_ROUTER, "10.10.10.0/24", 14).await?; + add_route(switch, None, "10.10.10.0/24", 14).await?; let inner = inner_packet(switch, "10.10.10.32"); let mut unreachable = inner.clone(); @@ -516,8 +520,8 @@ async fn test_longest_prefix_within_router() -> TestResult { let switch = &*get_switch().await; add_link_addr(switch).await?; let (a, _) = create_a_and_b(switch).await?; - add_route(switch, DEFAULT_ROUTER, "10.0.0.0/8", 14).await?; - add_route(switch, a, "10.1.0.0/16", 15).await?; + add_route(switch, None, "10.0.0.0/8", 14).await?; + add_route(switch, Some(a), "10.1.0.0/16", 15).await?; let inner = inner_packet(switch, "10.1.1.1"); switch.packet_test( diff --git a/dpd-client/tests/integration_tests/table_tests.rs b/dpd-client/tests/integration_tests/table_tests.rs index 8aed3fde..aa1ab794 100644 --- a/dpd-client/tests/integration_tests/table_tests.rs +++ b/dpd-client/tests/integration_tests/table_tests.rs @@ -412,18 +412,18 @@ impl TableTest for RouteV4 { }), replace: false, }; - switch.client.route_ipv4_set(&DEFAULT_ROUTER, &route).await + switch.client.route_ipv4_set(&route).await } async fn delete_entry(switch: &Switch, idx: usize) -> OpResult<()> { let cidr = gen_ipv4_cidr(idx); - switch.client.route_ipv4_delete(&DEFAULT_ROUTER, &cidr).await + switch.client.route_ipv4_delete(&cidr).await } async fn count_entries(switch: &Switch) -> usize { switch .client - .route_ipv4_list_stream(&DEFAULT_ROUTER, None) + .route_ipv4_list_stream(None) .try_collect::>() .await .unwrap() @@ -456,18 +456,18 @@ impl TableTest for RouteV6 { }, replace: false, }; - switch.client.route_ipv6_set(&DEFAULT_ROUTER, &route).await + switch.client.route_ipv6_set(&route).await } async fn delete_entry(switch: &Switch, idx: usize) -> OpResult<()> { let cidr = gen_ipv6_cidr(idx); - switch.client.route_ipv6_delete(&DEFAULT_ROUTER, &cidr).await + switch.client.route_ipv6_delete(&cidr).await } async fn count_entries(switch: &Switch) -> usize { switch .client - .route_ipv6_list_stream(&DEFAULT_ROUTER, None) + .route_ipv6_list_stream(None) .try_collect::>() .await .unwrap() diff --git a/openapi/dpd/dpd-14.0.0-152d98.json b/openapi/dpd/dpd-14.0.0-c16d01.json similarity index 96% rename from openapi/dpd/dpd-14.0.0-152d98.json rename to openapi/dpd/dpd-14.0.0-c16d01.json index ba2861f2..f52c2e11 100644 --- a/openapi/dpd/dpd-14.0.0-152d98.json +++ b/openapi/dpd/dpd-14.0.0-c16d01.json @@ -4827,6 +4827,457 @@ } } }, + "/route/ipv4": { + "get": { + "summary": "Fetch the IPv4 routes configured on the default routing table, mapping", + "description": "IPv4 CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", + "operationId": "route_ipv4_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv4RoutesResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, + "put": { + "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", + "description": "the default routing table.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", + "operationId": "route_ipv4_set", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv4RouteUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", + "description": "the default routing table.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", + "operationId": "route_ipv4_add", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv4RouteUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/route/ipv4/{cidr}": { + "get": { + "summary": "Get the configured route for the given IPv4 subnet on the default routing", + "description": "table.", + "operationId": "route_ipv4_get", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The IPv4 subnet in CIDR notation whose route entry is returned.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv4Net" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Route", + "type": "array", + "items": { + "$ref": "#/components/schemas/Route" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Remove all targets for the given subnet on the default routing table", + "operationId": "route_ipv4_delete", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The IPv4 subnet in CIDR notation whose route entry is returned.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv4Net" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}": { + "delete": { + "summary": "Remove a single target for the given IPv4 subnet on the default routing", + "description": "table (IPv4 or IPv6 next hop)", + "operationId": "route_ipv4_delete_target", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The subnet being routed", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv4Net" + } + }, + { + "in": "path", + "name": "link_id", + "description": "The link to which packets should be sent", + "required": true, + "schema": { + "$ref": "#/components/schemas/LinkId" + } + }, + { + "in": "path", + "name": "port_id", + "description": "The switch port to which packets should be sent", + "required": true, + "schema": { + "$ref": "#/components/schemas/PortId" + } + }, + { + "in": "path", + "name": "tgt_ip", + "description": "The next hop in the route (IPv4 or IPv6)", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/route/ipv6": { + "get": { + "summary": "Fetch the IPv6 routes configured on the default routing table, mapping", + "description": "IPv6 CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", + "operationId": "route_ipv6_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv6RoutesResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, + "put": { + "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the default", + "description": "routing table.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", + "operationId": "route_ipv6_set", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv6RouteUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the default", + "description": "routing table.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", + "operationId": "route_ipv6_add", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ipv6RouteUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/route/ipv6/{cidr}": { + "get": { + "summary": "Get a single IPv6 route on the default routing table, by its IPv6 CIDR", + "description": "block.", + "operationId": "route_ipv6_get", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The IPv6 subnet in CIDR notation whose route entry is returned.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv6Net" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Ipv6Route", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Route" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Remove an IPv6 route on the default routing table, by its IPv6 CIDR", + "description": "block.", + "operationId": "route_ipv6_delete", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The IPv6 subnet in CIDR notation whose route entry is returned.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv6Net" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}": { + "delete": { + "summary": "Remove a single target for the given IPv6 subnet on the default routing", + "description": "table", + "operationId": "route_ipv6_delete_target", + "parameters": [ + { + "in": "path", + "name": "cidr", + "description": "The subnet being routed", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ipv6Net" + } + }, + { + "in": "path", + "name": "link_id", + "description": "The link to which packets should be sent", + "required": true, + "schema": { + "$ref": "#/components/schemas/LinkId" + } + }, + { + "in": "path", + "name": "port_id", + "description": "The switch port to which packets should be sent", + "required": true, + "schema": { + "$ref": "#/components/schemas/PortId" + } + }, + { + "in": "path", + "name": "tgt_ip", + "description": "The next hop in the IPv4 route", + "required": true, + "schema": { + "type": "string", + "format": "ipv6" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/router": { "get": { "summary": "List the routers with their endpoints.", @@ -4966,7 +5417,7 @@ "get": { "summary": "Fetch the IPv4 routes configured on the given router, mapping IPv4", "description": "CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", - "operationId": "route_ipv4_list", + "operationId": "router_route_ipv4_list", "parameters": [ { "in": "path", @@ -5024,7 +5475,7 @@ "put": { "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", "description": "the given router.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", - "operationId": "route_ipv4_set", + "operationId": "router_route_ipv4_set", "parameters": [ { "in": "path", @@ -5062,7 +5513,7 @@ "post": { "summary": "Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on", "description": "the given router.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", - "operationId": "route_ipv4_add", + "operationId": "router_route_ipv4_add", "parameters": [ { "in": "path", @@ -5101,7 +5552,7 @@ "/router/{router_id}/route/ipv4/{cidr}": { "get": { "summary": "Get the configured route for the given IPv4 subnet on the given router.", - "operationId": "route_ipv4_get", + "operationId": "router_route_ipv4_get", "parameters": [ { "in": "path", @@ -5148,7 +5599,7 @@ }, "delete": { "summary": "Remove all targets for the given subnet on the given router.", - "operationId": "route_ipv4_delete", + "operationId": "router_route_ipv4_delete", "parameters": [ { "in": "path", @@ -5187,7 +5638,7 @@ "delete": { "summary": "Remove a single target for the given IPv4 subnet on the given router", "description": "(IPv4 or IPv6 next hop).", - "operationId": "route_ipv4_delete_target", + "operationId": "router_route_ipv4_delete_target", "parameters": [ { "in": "path", @@ -5254,7 +5705,7 @@ "get": { "summary": "Fetch the IPv6 routes configured on the given router, mapping IPv6", "description": "CIDR blocks to the switch port used for sending out that traffic, and optionally a gateway.", - "operationId": "route_ipv6_list", + "operationId": "router_route_ipv6_list", "parameters": [ { "in": "path", @@ -5312,7 +5763,7 @@ "put": { "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the given", "description": "router.\n\nThis call can be used to create a new single-path route or to replace any existing routes with a new single-path route.", - "operationId": "route_ipv6_set", + "operationId": "router_route_ipv6_set", "parameters": [ { "in": "path", @@ -5350,7 +5801,7 @@ "post": { "summary": "Route an IPv6 subnet to a link and a nexthop gateway on the given", "description": "router.\n\nThis call can be used to create a new single-path route or to add new targets to a multipath route.", - "operationId": "route_ipv6_add", + "operationId": "router_route_ipv6_add", "parameters": [ { "in": "path", @@ -5389,7 +5840,7 @@ "/router/{router_id}/route/ipv6/{cidr}": { "get": { "summary": "Get a single IPv6 route on the given router, by its IPv6 CIDR block.", - "operationId": "route_ipv6_get", + "operationId": "router_route_ipv6_get", "parameters": [ { "in": "path", @@ -5436,7 +5887,7 @@ }, "delete": { "summary": "Remove an IPv6 route on the given router, by its IPv6 CIDR block.", - "operationId": "route_ipv6_delete", + "operationId": "router_route_ipv6_delete", "parameters": [ { "in": "path", @@ -5474,7 +5925,7 @@ "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}": { "delete": { "summary": "Remove a single target for the given IPv6 subnet on the given router.", - "operationId": "route_ipv6_delete_target", + "operationId": "router_route_ipv6_delete_target", "parameters": [ { "in": "path", diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index 20e0c968..d6e4498f 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-14.0.0-152d98.json \ No newline at end of file +dpd-14.0.0-c16d01.json \ No newline at end of file diff --git a/swadm/src/route.rs b/swadm/src/route.rs index af91bc85..efa9b78f 100644 --- a/swadm/src/route.rs +++ b/swadm/src/route.rs @@ -158,7 +158,7 @@ async fn route_list( print_route_header(&mut tw)?; if v4 { let routes: Vec = client - .route_ipv4_list_stream(&router_id, None) + .router_route_ipv4_list_stream(&router_id, None) .try_collect() .await .context("failed to get IPv4 routes")?; @@ -168,7 +168,7 @@ async fn route_list( } if v6 { let routes: Vec = client - .route_ipv6_list_stream(&router_id, None) + .router_route_ipv6_list_stream(&router_id, None) .try_collect() .await .context("failed to get IPv6 routes")?; @@ -189,7 +189,7 @@ async fn route_get( match &cidr { IpNet::V4(c) => { let targets = client - .route_ipv4_get(&router_id, c) + .router_route_ipv4_get(&router_id, c) .await .context("failed to get IPv4 route")? .into_inner(); @@ -197,7 +197,7 @@ async fn route_get( } IpNet::V6(c) => { let targets = client - .route_ipv6_get(&router_id, c) + .router_route_ipv6_get(&router_id, c) .await .context("failed to get IPv6 route")? .into_inner(); @@ -238,7 +238,7 @@ async fn route_add( } }; client - .route_ipv4_add( + .router_route_ipv4_add( &router_id, &types::Ipv4RouteUpdate { cidr, target, replace: false }, ) @@ -247,7 +247,7 @@ async fn route_add( .map(|_| ()) } (IpAddr::V6(tgt_ip), IpNet::V6(cidr)) => client - .route_ipv6_add( + .router_route_ipv6_add( &router_id, &types::Ipv6RouteUpdate { cidr, @@ -289,13 +289,15 @@ async fn route_del( let port = link_path.port_id; let link = link_path.link_id; client - .route_ipv4_delete_target(&router_id, &c, &port, &link, &gw) + .router_route_ipv4_delete_target( + &router_id, &c, &port, &link, &gw, + ) .await .context("deleting IPv4 route target") .map(|_| ()) } None => client - .route_ipv4_delete(&router_id, &c) + .router_route_ipv4_delete(&router_id, &c) .await .context("deleting IPv4 route") .map(|_| ()), @@ -303,7 +305,7 @@ async fn route_del( IpNet::V6(c) => match gw { Some(_) => Err(anyhow!("not supported")), None => client - .route_ipv6_delete(&router_id, &c) + .router_route_ipv6_delete(&router_id, &c) .await .context("deleting IPv6 route") .map(|_| ()), From 1918d309bff2f65c074767c6469b1131cbb01973 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Mon, 2 Nov 2026 19:39:31 -0300 Subject: [PATCH 06/10] update stages and LPM size --- .github/buildomat/common.sh | 2 +- dpd-client/tests/integration_tests/table_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/buildomat/common.sh b/.github/buildomat/common.sh index 965e5b78..34cc04de 100644 --- a/.github/buildomat/common.sh +++ b/.github/buildomat/common.sh @@ -7,7 +7,7 @@ # track when we exceed the current ceiling. The underlying intention is to grow # deliberately and thoughtfully, given the limited space on the ASIC. if [ "$MULTICAST" == "" ]; then - TOFINO_STAGES=17 + TOFINO_STAGES=18 else TOFINO_STAGES=19 fi diff --git a/dpd-client/tests/integration_tests/table_tests.rs b/dpd-client/tests/integration_tests/table_tests.rs index ac65f5a2..61b074b9 100644 --- a/dpd-client/tests/integration_tests/table_tests.rs +++ b/dpd-client/tests/integration_tests/table_tests.rs @@ -46,7 +46,7 @@ use crate::integration_tests::common::prelude::*; // Investigate moving MulticastRouter4/6 into the egress pipeline to reclaim // capacity. #[cfg(feature = "multicast")] -const IPV4_LPM_SIZE: usize = 8145; // ipv4 forwarding table +const IPV4_LPM_SIZE: usize = 8153; // ipv4 forwarding table #[cfg(not(feature = "multicast"))] const IPV4_LPM_SIZE: usize = 8190; // ipv4 forwarding table From 0bb654a308ce181d4c90182c0cb64be1d7c7c898 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Tue, 3 Nov 2026 10:57:37 -0300 Subject: [PATCH 07/10] polish dpd-api changes --- dpd-api/src/lib.rs | 524 ++++++++---------- dpd-types/versions/src/lib.rs | 2 +- .../{multi_router => modular_router}/mod.rs | 8 +- .../{multi_router => modular_router}/route.rs | 3 +- dpd/src/api_server.rs | 442 +++++++++------ ...0.0-c16d01.json => dpd-14.0.0-adf422.json} | 4 +- openapi/dpd/dpd-latest.json | 2 +- 7 files changed, 503 insertions(+), 482 deletions(-) rename dpd-types/versions/src/{multi_router => modular_router}/mod.rs (56%) rename dpd-types/versions/src/{multi_router => modular_router}/route.rs (99%) rename openapi/dpd/{dpd-14.0.0-c16d01.json => dpd-14.0.0-adf422.json} (99%) diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index 6e81d559..657c2fe5 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -9,7 +9,6 @@ use std::collections::BTreeMap; use std::net::{Ipv4Addr, Ipv6Addr}; -use dpd_types_versions::latest::route::DEFAULT_ROUTER; use dpd_types_versions::{latest, v1, v4, v6, v7}; use dropshot::{ EmptyScanParams, HttpError, HttpResponseCreated, HttpResponseDeleted, @@ -31,7 +30,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), - (14, MULTI_ROUTER), + (14, MODULAR_ROUTER), (13, ALLOW_DDM_TRAFFIC), (12, PRBS_ERROR_TRACKING), (11, WALLCLOCK_HISTORY), @@ -184,25 +183,6 @@ pub trait DpdApi { path: Path, ) -> Result; - /** - * Fetch the IPv6 routes configured on the given router, mapping IPv6 - * CIDR blocks to the switch port used for sending out that traffic, and - * optionally a gateway. - */ - #[endpoint { - method = GET, - path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_list", - }] - async fn route_ipv6_list( - rqctx: RequestContext, - path: Path, - query: Query< - PaginationParams, - >, - ) -> Result>, HttpError>; - /** * Fetch the IPv6 routes configured on the default routing table, mapping * IPv6 CIDR blocks to the switch port used for sending out that traffic, @@ -211,33 +191,13 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv6", - versions = .., - operation_id = "route_ipv6_list", }] - async fn route_ipv6_list_v1( + async fn route_ipv6_list( rqctx: RequestContext, query: Query< PaginationParams, >, - ) -> Result>, HttpError> - { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv6_list(rqctx, path.into(), query).await - } - - /** - * Get a single IPv6 route on the given router, by its IPv6 CIDR block. - */ - #[endpoint { - method = GET, - path = "/router/{router_id}/route/ipv6/{cidr}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_get", - }] - async fn route_ipv6_get( - rqctx: RequestContext, - path: Path, - ) -> Result>, HttpError>; + ) -> Result>, HttpError>; /** * Get a single IPv6 route on the default routing table, by its IPv6 CIDR @@ -246,34 +206,11 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv6/{cidr}", - versions = .., - operation_id = "route_ipv6_get", }] - async fn route_ipv6_get_v1( + async fn route_ipv6_get( rqctx: RequestContext, path: Path, - ) -> Result>, HttpError> { - Self::route_ipv6_get(rqctx, path.map(Into::into)).await - } - - /** - * Route an IPv6 subnet to a link and a nexthop gateway on the given - * router. - * - * This call can be used to create a new single-path route or to add new - * targets to a multipath route. - */ - #[endpoint { - method = POST, - path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_add", - }] - async fn route_ipv6_add( - rqctx: RequestContext, - path: Path, - update: TypedBody, - ) -> Result; + ) -> Result>, HttpError>; /** * Route an IPv6 subnet to a link and a nexthop gateway on the default @@ -285,33 +222,9 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv6", - versions = .., - operation_id = "route_ipv6_add", - }] - async fn route_ipv6_add_v1( - rqctx: RequestContext, - update: TypedBody, - ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv6_add(rqctx, path.into(), update).await - } - - /** - * Route an IPv6 subnet to a link and a nexthop gateway on the given - * router. - * - * This call can be used to create a new single-path route or to replace - * any existing routes with a new single-path route. - */ - #[endpoint { - method = PUT, - path = "/router/{router_id}/route/ipv6", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_set", }] - async fn route_ipv6_set( + async fn route_ipv6_add( rqctx: RequestContext, - path: Path, update: TypedBody, ) -> Result; @@ -325,30 +238,11 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv6", - versions = .., - operation_id = "route_ipv6_set", }] - async fn route_ipv6_set_v1( + async fn route_ipv6_set( rqctx: RequestContext, update: TypedBody, - ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv6_set(rqctx, path.into(), update).await - } - - /** - * Remove an IPv6 route on the given router, by its IPv6 CIDR block. - */ - #[endpoint { - method = DELETE, - path = "/router/{router_id}/route/ipv6/{cidr}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_delete", - }] - async fn route_ipv6_delete( - rqctx: RequestContext, - path: Path, - ) -> Result; + ) -> Result; /** * Remove an IPv6 route on the default routing table, by its IPv6 CIDR @@ -357,28 +251,10 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/route/ipv6/{cidr}", - versions = .., - operation_id = "route_ipv6_delete", }] - async fn route_ipv6_delete_v1( + async fn route_ipv6_delete( rqctx: RequestContext, path: Path, - ) -> Result { - Self::route_ipv6_delete(rqctx, path.map(Into::into)).await - } - - /** - * Remove a single target for the given IPv6 subnet on the given router. - */ - #[endpoint { - method = DELETE, - path = "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv6_delete_target", - }] - async fn route_ipv6_delete_target( - rqctx: RequestContext, - path: Path, ) -> Result; /** @@ -388,34 +264,11 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = .., - operation_id = "route_ipv6_delete_target", }] - async fn route_ipv6_delete_target_v1( + async fn route_ipv6_delete_target( rqctx: RequestContext, path: Path, - ) -> Result { - Self::route_ipv6_delete_target(rqctx, path.map(Into::into)).await - } - - /** - * Fetch the IPv4 routes configured on the given router, mapping IPv4 - * CIDR blocks to the switch port used for sending out that traffic, and - * optionally a gateway. - */ - #[endpoint { - method = GET, - path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_list", - }] - async fn route_ipv4_list( - rqctx: RequestContext, - path: Path, - query: Query< - PaginationParams, - >, - ) -> Result>, HttpError>; + ) -> Result; /** * Fetch the IPv4 routes configured on the default routing table, mapping @@ -425,19 +278,14 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv4", - versions = VERSION_V4_OVER_V6_ROUTES.., - operation_id = "route_ipv4_list", + versions = VERSION_V4_OVER_V6_ROUTES.. }] - async fn route_ipv4_list_v4( + async fn route_ipv4_list( rqctx: RequestContext, query: Query< PaginationParams, >, - ) -> Result>, HttpError> - { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_list(rqctx, path.into(), query).await - } + ) -> Result>, HttpError>; /** * Fetch the configured IPv4 routes, mapping IPv4 CIDR blocks to the switch port @@ -455,28 +303,13 @@ pub trait DpdApi { >, ) -> Result>, HttpError> { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - let page = Self::route_ipv4_list(rqctx, path.into(), query).await?.0; + let page = Self::route_ipv4_list(rqctx, query).await?.0; Ok(HttpResponseOk(ResultsPage { next_page: page.next_page, items: page.items.into_iter().map(Into::into).collect(), })) } - /** - * Get the configured route for the given IPv4 subnet on the given router. - */ - #[endpoint { - method = GET, - path = "/router/{router_id}/route/ipv4/{cidr}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_get", - }] - async fn route_ipv4_get( - rqctx: RequestContext, - path: Path, - ) -> Result>, HttpError>; - /** * Get the configured route for the given IPv4 subnet on the default routing * table. @@ -484,15 +317,12 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/route/ipv4/{cidr}", - versions = VERSION_V4_OVER_V6_ROUTES.., - operation_id = "route_ipv4_get", + versions = VERSION_V4_OVER_V6_ROUTES.. }] - async fn route_ipv4_get_v4( + async fn route_ipv4_get( rqctx: RequestContext, path: Path, - ) -> Result>, HttpError> { - Self::route_ipv4_get(rqctx, path.map(Into::into)).await - } + ) -> Result>, HttpError>; #[endpoint { method = GET, @@ -503,7 +333,7 @@ pub trait DpdApi { rqctx: RequestContext, path: Path, ) -> Result>, HttpError> { - let result = Self::route_ipv4_get(rqctx, path.map(Into::into)).await?.0; + let result = Self::route_ipv4_get(rqctx, path).await?.0; Ok(HttpResponseOk( result .into_iter() @@ -515,25 +345,6 @@ pub trait DpdApi { )) } - /** - * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on - * the given router. - * - * This call can be used to create a new single-path route or to add new - * targets to a multipath route. - */ - #[endpoint { - method = POST, - path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_add", - }] - async fn route_ipv4_add( - rqctx: RequestContext, - path: Path, - update: TypedBody, - ) -> Result; - /** * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on * the default routing table. @@ -544,16 +355,12 @@ pub trait DpdApi { #[endpoint { method = POST, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES.., - operation_id = "route_ipv4_add", + versions = VERSION_CONSOLIDATED_V4_ROUTES.. }] - async fn route_ipv4_add_v6( + async fn route_ipv4_add( rqctx: RequestContext, update: TypedBody, - ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_add(rqctx, path.into(), update).await - } + ) -> Result; /** * Route an IPv4 subnet to a link and an IPv6 nexthop gateway. @@ -571,8 +378,7 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_add(rqctx, path.into(), update.map(Into::into)).await + Self::route_ipv4_add(rqctx, update.map(Into::into)).await } /** @@ -591,29 +397,9 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_add(rqctx, path.into(), update.map(Into::into)).await + Self::route_ipv4_add(rqctx, update.map(Into::into)).await } - /** - * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on - * the given router. - * - * This call can be used to create a new single-path route or to replace - * any existing routes with a new single-path route. - */ - #[endpoint { - method = PUT, - path = "/router/{router_id}/route/ipv4", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_set", - }] - async fn route_ipv4_set( - rqctx: RequestContext, - path: Path, - update: TypedBody, - ) -> Result; - /** * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on * the default routing table. @@ -624,16 +410,12 @@ pub trait DpdApi { #[endpoint { method = PUT, path = "/route/ipv4", - versions = VERSION_CONSOLIDATED_V4_ROUTES.., - operation_id = "route_ipv4_set", + versions = VERSION_CONSOLIDATED_V4_ROUTES.. }] - async fn route_ipv4_set_v6( + async fn route_ipv4_set( rqctx: RequestContext, update: TypedBody, - ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_set(rqctx, path.into(), update).await - } + ) -> Result; /** * Route an IPv4 subnet to a link and an IPv6 nexthop gateway. @@ -651,8 +433,7 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_set(rqctx, path.into(), update.map(Into::into)).await + Self::route_ipv4_set(rqctx, update.map(Into::into)).await } /** @@ -671,53 +452,19 @@ pub trait DpdApi { rqctx: RequestContext, update: TypedBody, ) -> Result { - let path = latest::route::RouterPath { router_id: DEFAULT_ROUTER }; - Self::route_ipv4_set(rqctx, path.into(), update.map(Into::into)).await + Self::route_ipv4_set(rqctx, update.map(Into::into)).await } - /** - * Remove all targets for the given subnet on the given router. - */ - #[endpoint { - method = DELETE, - path = "/router/{router_id}/route/ipv4/{cidr}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_delete", - }] - async fn route_ipv4_delete( - rqctx: RequestContext, - path: Path, - ) -> Result; - /** * Remove all targets for the given subnet on the default routing table */ #[endpoint { method = DELETE, path = "/route/ipv4/{cidr}", - versions = .., - operation_id = "route_ipv4_delete", }] - async fn route_ipv4_delete_v1( + async fn route_ipv4_delete( rqctx: RequestContext, path: Path, - ) -> Result { - Self::route_ipv4_delete(rqctx, path.map(Into::into)).await - } - - /** - * Remove a single target for the given IPv4 subnet on the given router - * (IPv4 or IPv6 next hop). - */ - #[endpoint { - method = DELETE, - path = "/router/{router_id}/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_MULTI_ROUTER.., - operation_id = "router_route_ipv4_delete_target", - }] - async fn route_ipv4_delete_target( - rqctx: RequestContext, - path: Path, ) -> Result; /** @@ -727,15 +474,12 @@ pub trait DpdApi { #[endpoint { method = DELETE, path = "/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", - versions = VERSION_CONSOLIDATED_V4_ROUTES.., - operation_id = "route_ipv4_delete_target", + versions = VERSION_CONSOLIDATED_V4_ROUTES.. }] - async fn route_ipv4_delete_target_v6( + async fn route_ipv4_delete_target( rqctx: RequestContext, path: Path, - ) -> Result { - Self::route_ipv4_delete_target(rqctx, path.map(Into::into)).await - } + ) -> Result; /** * Remove a single target for the given IPv4 subnet @@ -750,8 +494,7 @@ pub trait DpdApi { rqctx: RequestContext, path: Path, ) -> Result { - let path = path.map(|p| v6::route::RouteTargetIpv4Path::from(p).into()); - Self::route_ipv4_delete_target(rqctx, path).await + Self::route_ipv4_delete_target(rqctx, path.map(Into::into)).await } /** @@ -760,7 +503,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MODULAR_ROUTER.. }] async fn router_list( rqctx: RequestContext, @@ -772,7 +515,7 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/router/{router_id}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MODULAR_ROUTER.. }] async fn router_get( rqctx: RequestContext, @@ -782,12 +525,12 @@ pub trait DpdApi { /** * Create a router with the given endpoint. An address can be the * endpoint of only one router, and a router's endpoint can't be changed. - * The default router's uuid names table 0 and can't be used. + * The default routing table's uuid can't be used. */ #[endpoint { method = PUT, path = "/router/{router_id}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MODULAR_ROUTER.. }] async fn router_create( rqctx: RequestContext, @@ -797,18 +540,205 @@ pub trait DpdApi { /** * Delete a router, along with its endpoint and routes. The default - * router's uuid names table 0 and can't be used. + * routing table's uuid can't be used. */ #[endpoint { method = DELETE, path = "/router/{router_id}", - versions = VERSION_MULTI_ROUTER.. + versions = VERSION_MODULAR_ROUTER.. }] async fn router_delete( rqctx: RequestContext, path: Path, ) -> Result; + /** + * Fetch the IPv6 routes configured on the given router, mapping IPv6 + * CIDR blocks to the switch port used for sending out that traffic, and + * optionally a gateway. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + + /** + * Get a single IPv6 route on the given router, by its IPv6 CIDR block. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv6/{cidr}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_get( + rqctx: RequestContext, + path: Path, + ) -> Result>, HttpError>; + + /** + * Route an IPv6 subnet to a link and a nexthop gateway on the given + * router. + * + * This call can be used to create a new single-path route or to add new + * targets to a multipath route. + */ + #[endpoint { + method = POST, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_add( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + + /** + * Route an IPv6 subnet to a link and a nexthop gateway on the given + * router. + * + * This call can be used to create a new single-path route or to replace + * any existing routes with a new single-path route. + */ + #[endpoint { + method = PUT, + path = "/router/{router_id}/route/ipv6", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_set( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + + /** + * Remove an IPv6 route on the given router, by its IPv6 CIDR block. + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv6/{cidr}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_delete( + rqctx: RequestContext, + path: Path, + ) -> Result; + + /** + * Remove a single target for the given IPv6 subnet on the given router. + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv6/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv6_delete_target( + rqctx: RequestContext, + path: Path, + ) -> Result; + + /** + * Fetch the IPv4 routes configured on the given router, mapping IPv4 + * CIDR blocks to the switch port used for sending out that traffic, and + * optionally a gateway. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_list( + rqctx: RequestContext, + path: Path, + query: Query< + PaginationParams, + >, + ) -> Result>, HttpError>; + + /** + * Get the configured route for the given IPv4 subnet on the given router. + */ + #[endpoint { + method = GET, + path = "/router/{router_id}/route/ipv4/{cidr}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_get( + rqctx: RequestContext, + path: Path, + ) -> Result>, HttpError>; + + /** + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the given router. + * + * This call can be used to create a new single-path route or to add new + * targets to a multipath route. + */ + #[endpoint { + method = POST, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_add( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + + /** + * Route an IPv4 subnet to a link and a nexthop gateway (IPv4 or IPv6) on + * the given router. + * + * This call can be used to create a new single-path route or to replace + * any existing routes with a new single-path route. + */ + #[endpoint { + method = PUT, + path = "/router/{router_id}/route/ipv4", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_set( + rqctx: RequestContext, + path: Path, + update: TypedBody, + ) -> Result; + + /** + * Remove all targets for the given subnet on the given router. + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv4/{cidr}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_delete( + rqctx: RequestContext, + path: Path, + ) -> Result; + + /** + * Remove a single target for the given IPv4 subnet on the given router + * (IPv4 or IPv6 next hop). + */ + #[endpoint { + method = DELETE, + path = "/router/{router_id}/route/ipv4/{cidr}/{port_id}/{link_id}/{tgt_ip}", + versions = VERSION_MODULAR_ROUTER.., + }] + async fn router_route_ipv4_delete_target( + rqctx: RequestContext, + path: Path, + ) -> Result; + /// List all switch ports on the system. #[endpoint { method = GET, diff --git a/dpd-types/versions/src/lib.rs b/dpd-types/versions/src/lib.rs index 00fbafbc..d7a88d66 100644 --- a/dpd-types/versions/src/lib.rs +++ b/dpd-types/versions/src/lib.rs @@ -43,7 +43,7 @@ pub mod v11; pub mod v12; #[path = "allow_ddm_traffic/mod.rs"] pub mod v13; -#[path = "multi_router/mod.rs"] +#[path = "modular_router/mod.rs"] pub mod v14; #[path = "attached_subnets/mod.rs"] pub mod v3; diff --git a/dpd-types/versions/src/multi_router/mod.rs b/dpd-types/versions/src/modular_router/mod.rs similarity index 56% rename from dpd-types/versions/src/multi_router/mod.rs rename to dpd-types/versions/src/modular_router/mod.rs index f8bfc4c4..fdf701d6 100644 --- a/dpd-types/versions/src/multi_router/mod.rs +++ b/dpd-types/versions/src/modular_router/mod.rs @@ -4,10 +4,10 @@ // // Copyright 2026 Oxide Computer Company -//! Version `MULTI_ROUTER` of the DPD API. +//! Version `MODULAR_ROUTER` of the DPD API. //! -//! Adds routers, each with its own routes and endpoint. Route paths move -//! under `/router/{router_id}/...`; earlier versions act on the default -//! router. +//! Adds routers, each with its own routes and endpoint, under +//! `/router/{router_id}/...`. The `/route/...` endpoints act on the default +//! routing table. pub mod route; diff --git a/dpd-types/versions/src/multi_router/route.rs b/dpd-types/versions/src/modular_router/route.rs similarity index 99% rename from dpd-types/versions/src/multi_router/route.rs rename to dpd-types/versions/src/modular_router/route.rs index ef378995..da816aeb 100644 --- a/dpd-types/versions/src/multi_router/route.rs +++ b/dpd-types/versions/src/modular_router/route.rs @@ -74,7 +74,8 @@ pub struct RouteTargetIpv6Path { pub tgt_ip: Ipv6Addr, } -// Paths from earlier versions have no router, and address the default router. +// Paths from earlier versions have no router, and address the default routing +// table. impl From for RoutePathV4 { fn from(old: v1::route::RoutePathV4) -> Self { diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index 1203ad76..b4af6a11 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -51,10 +51,10 @@ use dpd_types::port::{ }; use dpd_types::port_map::BackplaneLink; use dpd_types::route::{ - AttachedSubnetToken, Ipv4RouteToken, Ipv4RouteUpdate, Ipv4Routes, - Ipv6Route, Ipv6RouteToken, Ipv6RouteUpdate, Ipv6Routes, Route, RoutePathV4, - RoutePathV6, RouteTarget, RouteTargetIpv4Path, RouteTargetIpv6Path, - RouterPath, SubnetPath, + AttachedSubnetToken, DEFAULT_ROUTER, Ipv4RouteToken, Ipv4RouteUpdate, + Ipv4Routes, Ipv6Route, Ipv6RouteToken, Ipv6RouteUpdate, Ipv6Routes, Route, + RoutePathV4, RoutePathV6, RouteTarget, RouteTargetIpv4Path, + RouteTargetIpv6Path, RouterPath, SubnetPath, }; use dpd_types::serdes::{ AnLtStatus, Ber, DfeAdaptationState, EncSpeed, LaneMap, RxSigInfo, @@ -69,7 +69,7 @@ use dpd_types::switch_port::{Led, LedState, ManagementMode, SwitchPortView}; use dpd_types::table; use dpd_types::table::TableParam; use dpd_types::transceivers::{Datapath, Monitors, PowerState, Transceiver}; -use dpd_types_versions::{v1, v7}; +use dpd_types_versions::{v1, v6, v7}; use dropshot::BuildError; use dropshot::ClientErrorStatusCode; use dropshot::ClientSpecifiesVersionInHeader; @@ -334,239 +334,92 @@ impl DpdApi for DpdApiImpl { async fn route_ipv6_list( rqctx: RequestContext>, - path: Path, query: Query>, ) -> Result>, HttpError> { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let pag_params = query.into_inner(); - let max = rqctx.page_limit(&pag_params)?.get(); - - let previous = match &pag_params.page { - WhichPage::First(..) => None, - WhichPage::Next(Ipv6RouteToken { cidr }) => Some(*cidr), - }; - - route::get_range_ipv6(switch, router, previous, max) - .await - .map_err(HttpError::from) - .and_then(|entries| { - ResultsPage::new( - entries, - &EmptyScanParams {}, - |e: &Ipv6Routes, _| Ipv6RouteToken { cidr: e.cidr }, - ) - }) - .map(HttpResponseOk) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv6_list(rqctx, path.into(), query).await } async fn route_ipv6_get( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result>, HttpError> { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - route::get_route_ipv6(switch, path.router_id.into(), path.cidr) - .await - .map(HttpResponseOk) - .map_err(HttpError::from) + Self::router_route_ipv6_get(rqctx, path.map(Into::into)).await } async fn route_ipv6_add( rqctx: RequestContext>, - path: Path, update: TypedBody, ) -> Result { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let route = update.into_inner(); - route::add_route_ipv6(switch, router, route.cidr, route.target) - .await - .map(|_| HttpResponseUpdatedNoContent()) - .map_err(HttpError::from) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv6_add(rqctx, path.into(), update).await } async fn route_ipv6_set( rqctx: RequestContext>, - path: Path, update: TypedBody, ) -> Result { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let route = update.into_inner(); - route::set_route_ipv6( - switch, - router, - route.cidr, - route.target, - route.replace, - ) - .await - .map(|_| HttpResponseUpdatedNoContent()) - .map_err(HttpError::from) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv6_set(rqctx, path.into(), update).await } async fn route_ipv6_delete( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - route::delete_route_ipv6(switch, path.router_id.into(), path.cidr) - .await - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Self::router_route_ipv6_delete(rqctx, path.map(Into::into)).await } async fn route_ipv6_delete_target( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - let subnet = path.cidr; - let port_id = path.port_id; - let link_id = path.link_id; - let tgt_ip = path.tgt_ip; - route::delete_route_target_ipv6( - switch, - path.router_id.into(), - subnet, - port_id, - link_id, - tgt_ip, - ) - .await - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Self::router_route_ipv6_delete_target(rqctx, path.map(Into::into)).await } async fn route_ipv4_list( rqctx: RequestContext>, - path: Path, query: Query>, ) -> Result>, HttpError> { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let pag_params = query.into_inner(); - let max = rqctx.page_limit(&pag_params)?.get(); - - let previous = match &pag_params.page { - WhichPage::First(..) => None, - WhichPage::Next(Ipv4RouteToken { cidr }) => Some(*cidr), - }; - - route::get_range_ipv4(switch, router, previous, max) - .await - .map_err(HttpError::from) - .and_then(|entries| { - ResultsPage::new( - entries, - &EmptyScanParams {}, - |e: &Ipv4Routes, _| Ipv4RouteToken { cidr: e.cidr }, - ) - }) - .map(HttpResponseOk) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv4_list(rqctx, path.into(), query).await } async fn route_ipv4_get( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result>, HttpError> { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - route::get_route_ipv4(switch, path.router_id.into(), path.cidr) - .await - .map(HttpResponseOk) - .map_err(HttpError::from) + Self::router_route_ipv4_get(rqctx, path.map(Into::into)).await } async fn route_ipv4_add( rqctx: RequestContext>, - path: Path, update: TypedBody, ) -> Result { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let route = update.into_inner(); - match route.target { - RouteTarget::V4(target) => { - route::add_route_ipv4(switch, router, route.cidr, target).await - } - RouteTarget::V6(target) => { - route::add_route_ipv4_over_ipv6( - switch, router, route.cidr, target, - ) - .await - } - } - .map(|_| HttpResponseUpdatedNoContent()) - .map_err(HttpError::from) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv4_add(rqctx, path.into(), update).await } async fn route_ipv4_set( rqctx: RequestContext>, - path: Path, update: TypedBody, ) -> Result { - let switch: &Switch = rqctx.context(); - let router = RouterUuid::from(path.into_inner().router_id); - let route = update.into_inner(); - match route.target { - RouteTarget::V4(target) => { - route::set_route_ipv4( - switch, - router, - route.cidr, - target, - route.replace, - ) - .await - } - RouteTarget::V6(target) => { - route::set_route_ipv4_over_ipv6( - switch, - router, - route.cidr, - target, - route.replace, - ) - .await - } - } - .map(|_| HttpResponseUpdatedNoContent()) - .map_err(HttpError::from) + let path = RouterPath { router_id: DEFAULT_ROUTER }; + Self::router_route_ipv4_set(rqctx, path.into(), update).await } async fn route_ipv4_delete( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - route::delete_route_ipv4(switch, path.router_id.into(), path.cidr) - .await - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Self::router_route_ipv4_delete(rqctx, path.map(Into::into)).await } async fn route_ipv4_delete_target( rqctx: RequestContext>, - path: Path, + path: Path, ) -> Result { - let switch: &Switch = rqctx.context(); - let path = path.into_inner(); - route::delete_route_target_ipv4( - switch, - path.router_id.into(), - path.cidr, - path.port_id, - path.link_id, - path.tgt_ip, - ) - .await - .map(|_| HttpResponseDeleted()) - .map_err(HttpError::from) + Self::router_route_ipv4_delete_target(rqctx, path.map(Into::into)).await } async fn port_list( @@ -1496,6 +1349,243 @@ impl DpdApi for DpdApiImpl { .map_err(HttpError::from) } + async fn router_route_ipv6_list( + rqctx: RequestContext>, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let pag_params = query.into_inner(); + let max = rqctx.page_limit(&pag_params)?.get(); + + let previous = match &pag_params.page { + WhichPage::First(..) => None, + WhichPage::Next(Ipv6RouteToken { cidr }) => Some(*cidr), + }; + + route::get_range_ipv6(switch, router, previous, max) + .await + .map_err(HttpError::from) + .and_then(|entries| { + ResultsPage::new( + entries, + &EmptyScanParams {}, + |e: &Ipv6Routes, _| Ipv6RouteToken { cidr: e.cidr }, + ) + }) + .map(HttpResponseOk) + } + + async fn router_route_ipv6_get( + rqctx: RequestContext>, + path: Path, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + route::get_route_ipv6(switch, path.router_id.into(), path.cidr) + .await + .map(HttpResponseOk) + .map_err(HttpError::from) + } + + async fn router_route_ipv6_add( + rqctx: RequestContext>, + path: Path, + update: TypedBody, + ) -> Result { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let route = update.into_inner(); + route::add_route_ipv6(switch, router, route.cidr, route.target) + .await + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) + } + + async fn router_route_ipv6_set( + rqctx: RequestContext>, + path: Path, + update: TypedBody, + ) -> Result { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let route = update.into_inner(); + route::set_route_ipv6( + switch, + router, + route.cidr, + route.target, + route.replace, + ) + .await + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) + } + + async fn router_route_ipv6_delete( + rqctx: RequestContext>, + path: Path, + ) -> Result { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + route::delete_route_ipv6(switch, path.router_id.into(), path.cidr) + .await + .map(|_| HttpResponseDeleted()) + .map_err(HttpError::from) + } + + async fn router_route_ipv6_delete_target( + rqctx: RequestContext>, + path: Path, + ) -> Result { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + let subnet = path.cidr; + let port_id = path.port_id; + let link_id = path.link_id; + let tgt_ip = path.tgt_ip; + route::delete_route_target_ipv6( + switch, + path.router_id.into(), + subnet, + port_id, + link_id, + tgt_ip, + ) + .await + .map(|_| HttpResponseDeleted()) + .map_err(HttpError::from) + } + + async fn router_route_ipv4_list( + rqctx: RequestContext>, + path: Path, + query: Query>, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let pag_params = query.into_inner(); + let max = rqctx.page_limit(&pag_params)?.get(); + + let previous = match &pag_params.page { + WhichPage::First(..) => None, + WhichPage::Next(Ipv4RouteToken { cidr }) => Some(*cidr), + }; + + route::get_range_ipv4(switch, router, previous, max) + .await + .map_err(HttpError::from) + .and_then(|entries| { + ResultsPage::new( + entries, + &EmptyScanParams {}, + |e: &Ipv4Routes, _| Ipv4RouteToken { cidr: e.cidr }, + ) + }) + .map(HttpResponseOk) + } + + async fn router_route_ipv4_get( + rqctx: RequestContext>, + path: Path, + ) -> Result>, HttpError> { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + route::get_route_ipv4(switch, path.router_id.into(), path.cidr) + .await + .map(HttpResponseOk) + .map_err(HttpError::from) + } + + async fn router_route_ipv4_add( + rqctx: RequestContext>, + path: Path, + update: TypedBody, + ) -> Result { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let route = update.into_inner(); + match route.target { + RouteTarget::V4(target) => { + route::add_route_ipv4(switch, router, route.cidr, target).await + } + RouteTarget::V6(target) => { + route::add_route_ipv4_over_ipv6( + switch, router, route.cidr, target, + ) + .await + } + } + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) + } + + async fn router_route_ipv4_set( + rqctx: RequestContext>, + path: Path, + update: TypedBody, + ) -> Result { + let switch: &Switch = rqctx.context(); + let router = RouterUuid::from(path.into_inner().router_id); + let route = update.into_inner(); + match route.target { + RouteTarget::V4(target) => { + route::set_route_ipv4( + switch, + router, + route.cidr, + target, + route.replace, + ) + .await + } + RouteTarget::V6(target) => { + route::set_route_ipv4_over_ipv6( + switch, + router, + route.cidr, + target, + route.replace, + ) + .await + } + } + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) + } + + async fn router_route_ipv4_delete( + rqctx: RequestContext>, + path: Path, + ) -> Result { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + route::delete_route_ipv4(switch, path.router_id.into(), path.cidr) + .await + .map(|_| HttpResponseDeleted()) + .map_err(HttpError::from) + } + + async fn router_route_ipv4_delete_target( + rqctx: RequestContext>, + path: Path, + ) -> Result { + let switch: &Switch = rqctx.context(); + let path = path.into_inner(); + route::delete_route_target_ipv4( + switch, + path.router_id.into(), + path.cidr, + path.port_id, + path.link_id, + path.tgt_ip, + ) + .await + .map(|_| HttpResponseDeleted()) + .map_err(HttpError::from) + } + async fn nat_ipv6_addresses_list( rqctx: RequestContext>, query: Query>, diff --git a/openapi/dpd/dpd-14.0.0-c16d01.json b/openapi/dpd/dpd-14.0.0-adf422.json similarity index 99% rename from openapi/dpd/dpd-14.0.0-c16d01.json rename to openapi/dpd/dpd-14.0.0-adf422.json index f52c2e11..cd87ad05 100644 --- a/openapi/dpd/dpd-14.0.0-c16d01.json +++ b/openapi/dpd/dpd-14.0.0-adf422.json @@ -5346,7 +5346,7 @@ }, "put": { "summary": "Create a router with the given endpoint. An address can be the", - "description": "endpoint of only one router, and a router's endpoint can't be changed. The default router's uuid names table 0 and can't be used.", + "description": "endpoint of only one router, and a router's endpoint can't be changed. The default routing table's uuid can't be used.", "operationId": "router_create", "parameters": [ { @@ -5386,7 +5386,7 @@ }, "delete": { "summary": "Delete a router, along with its endpoint and routes. The default", - "description": "router's uuid names table 0 and can't be used.", + "description": "routing table's uuid can't be used.", "operationId": "router_delete", "parameters": [ { diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index d6e4498f..888260c6 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-14.0.0-c16d01.json \ No newline at end of file +dpd-14.0.0-adf422.json \ No newline at end of file From df51c130f5dc4b274dae158c5ab30fadb1145f23 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Tue, 3 Nov 2026 11:39:22 -0300 Subject: [PATCH 08/10] make --router-id optional, defaulting to the default routing table --- swadm/src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/swadm/src/main.rs b/swadm/src/main.rs index e64e5bfb..de8be1ba 100644 --- a/swadm/src/main.rs +++ b/swadm/src/main.rs @@ -57,9 +57,9 @@ enum Commands { cmd: arp::Arp, }, Route { - /// router to operate on (nil uuid = default) - #[clap(long, global = true, default_value_t = Uuid::nil())] - router_id: Uuid, + /// router to operate on (default: the default routing table) + #[clap(long, global = true)] + router_id: Option, #[command(subcommand)] cmd: route::Route, }, @@ -247,6 +247,8 @@ async fn main_impl() -> anyhow::Result<()> { Commands::DpdBuildInfo => build_info(&client).await, Commands::Arp { cmd: a } => arp::arp_cmd(&client, a).await, Commands::Route { router_id, cmd: r } => { + // The nil uuid names the default routing table. + let router_id = router_id.unwrap_or(Uuid::nil()); route::route_cmd(&client, router_id, r).await } Commands::Addr { cmd: p } => addr::addr_cmd(&client, p).await, From 4584f4a7923ed7480f03db08d2bd439aa4e632c4 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Tue, 3 Nov 2026 12:05:04 -0300 Subject: [PATCH 09/10] add swadm tests --- swadm/tests/cli/expect/route_get_default.txt | 2 + swadm/tests/cli/expect/router_get.txt | 1 + swadm/tests/cli/expect/router_get_404.txt | 4 + swadm/tests/cli/expect/router_list.txt | 1 + swadm/tests/cli/expect/router_route_list.txt | 2 + swadm/tests/cli/main.rs | 1 + swadm/tests/cli/router.rs | 79 ++++++++++++++++++++ 7 files changed, 90 insertions(+) create mode 100644 swadm/tests/cli/expect/route_get_default.txt create mode 100644 swadm/tests/cli/expect/router_get.txt create mode 100644 swadm/tests/cli/expect/router_get_404.txt create mode 100644 swadm/tests/cli/expect/router_list.txt create mode 100644 swadm/tests/cli/expect/router_route_list.txt create mode 100644 swadm/tests/cli/router.rs diff --git a/swadm/tests/cli/expect/route_get_default.txt b/swadm/tests/cli/expect/route_get_default.txt new file mode 100644 index 00000000..b6acbc2d --- /dev/null +++ b/swadm/tests/cli/expect/route_get_default.txt @@ -0,0 +1,2 @@ +Subnet Port Link Gateway Vlan +198.51.100.0/24 rear0 0 192.0.2.1 diff --git a/swadm/tests/cli/expect/router_get.txt b/swadm/tests/cli/expect/router_get.txt new file mode 100644 index 00000000..17b86de9 --- /dev/null +++ b/swadm/tests/cli/expect/router_get.txt @@ -0,0 +1 @@ +fd00:1122::1 diff --git a/swadm/tests/cli/expect/router_get_404.txt b/swadm/tests/cli/expect/router_get_404.txt new file mode 100644 index 00000000..6a56970d --- /dev/null +++ b/swadm/tests/cli/expect/router_get_404.txt @@ -0,0 +1,4 @@ +Error: failed to get router + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/router_list.txt b/swadm/tests/cli/expect/router_list.txt new file mode 100644 index 00000000..b8c3081e --- /dev/null +++ b/swadm/tests/cli/expect/router_list.txt @@ -0,0 +1 @@ +11111111-2222-4333-8444-555555555555 fd00:1122::1 diff --git a/swadm/tests/cli/expect/router_route_list.txt b/swadm/tests/cli/expect/router_route_list.txt new file mode 100644 index 00000000..b6acbc2d --- /dev/null +++ b/swadm/tests/cli/expect/router_route_list.txt @@ -0,0 +1,2 @@ +Subnet Port Link Gateway Vlan +198.51.100.0/24 rear0 0 192.0.2.1 diff --git a/swadm/tests/cli/main.rs b/swadm/tests/cli/main.rs index 722a07a6..d11ea2dc 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 router; mod tx_eq; diff --git a/swadm/tests/cli/router.rs b/swadm/tests/cli/router.rs new file mode 100644 index 00000000..add4fcaf --- /dev/null +++ b/swadm/tests/cli/router.rs @@ -0,0 +1,79 @@ +// 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 anyhow::bail; +use serial_test::serial; + +use crate::cmd; + +const ROUTER: &str = "11111111-2222-4333-8444-555555555555"; +const DEFAULT_ROUTER: &str = "00000000-0000-0000-0000-000000000000"; +const ENDPOINT: &str = "fd00:1122::1"; +const PORT: &str = "rear0"; +const LINK: &str = "rear0/0"; +const SUBNET: &str = "198.51.100.0/24"; +const GW: &str = "192.0.2.1"; + +/// Creates a router, reads it back, and deletes it. +#[test] +#[serial] +#[ignore] +fn router_create_get_delete() -> anyhow::Result<()> { + cmd::swadm(format!("router create {ROUTER} --endpoint {ENDPOINT}"))?; + + cmd::swadm(format!("router get {ROUTER}"))? + .try_expectorate("router_get.txt")?; + cmd::swadm("router list")? + .retain_lines(&crate::among![ROUTER]?) + .try_expectorate("router_list.txt")?; + + cmd::swadm(format!("router delete {ROUTER}"))?; + + let mut out: cmd::Output = cmd::swadm(format!("router get {ROUTER}")) + .expect_err("Get cmd on deleted router should fail") + .try_into()?; + // Remove headers with UUID and timestamp + out.trunc_at("headers:").try_expectorate("router_get_404.txt") +} + +/// Adds a route to a router, and checks that it's listed on that router +/// and not on the default routing table. +#[test] +#[serial] +#[ignore] +fn router_route_add() -> anyhow::Result<()> { + crate::link_apply::delete_link(LINK)?; + + cmd::swadm(format!("link create {PORT} -s 100g --fec rs"))?; + cmd::swadm(format!("router create {ROUTER} --endpoint {ENDPOINT}"))?; + cmd::swadm(format!("route add --router-id {ROUTER} {SUBNET} {LINK} {GW}"))?; + + cmd::swadm(format!("route list v4 --router-id {ROUTER}"))? + .try_expectorate("router_route_list.txt")?; + + let default = cmd::swadm("route list v4")?; + if default.as_ref().contains(SUBNET) { + bail!("Route on a router should not be on the default routing table"); + } + Ok(()) +} + +/// A route command without `--router-id` acts on the default routing table. +#[test] +#[serial] +#[ignore] +fn route_default_routing_table() -> anyhow::Result<()> { + crate::link_apply::delete_link(LINK)?; + + cmd::swadm(format!("link create {PORT} -s 100g --fec rs"))?; + cmd::swadm(format!("route add {SUBNET} {LINK} {GW}"))?; + + cmd::swadm(format!("route get --router-id {DEFAULT_ROUTER} {SUBNET}"))? + .try_expectorate("route_get_default.txt")?; + + cmd::swadm(format!("route del {SUBNET}"))?; + Ok(()) +} From c424a79e13db858de98a0539d7f056f26ef36ef1 Mon Sep 17 00:00:00 2001 From: Nicolas Kagami Date: Tue, 3 Nov 2026 14:59:55 -0300 Subject: [PATCH 10/10] fix typo --- dpd-types/versions/src/modular_router/route.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dpd-types/versions/src/modular_router/route.rs b/dpd-types/versions/src/modular_router/route.rs index da816aeb..4631f4a6 100644 --- a/dpd-types/versions/src/modular_router/route.rs +++ b/dpd-types/versions/src/modular_router/route.rs @@ -17,8 +17,7 @@ use uuid::Uuid; use crate::v1::link::LinkId; /// The logical router used for backwards compatibility, sharing routing table -/// 0 with the underlay routes. This is to be removed when compatibility in a -/// subsequent version. +/// 0 with the underlay routes. This is to be removed in a subsequent version. pub const DEFAULT_ROUTER: Uuid = Uuid::nil(); #[derive(Deserialize, Serialize, JsonSchema)]