Skip to content

Add External API endpoints for BGP unnumbered interface manager status#10705

Open
taspelund wants to merge 5 commits into
mainfrom
trey/bgp-unnumbered-status
Open

Add External API endpoints for BGP unnumbered interface manager status#10705
taspelund wants to merge 5 commits into
mainfrom
trey/bgp-unnumbered-status

Conversation

@taspelund

Copy link
Copy Markdown
Contributor

This is the omicron side of maghemite#800.

In mgd, we are refactoring the internals of BGP unnumbered which affects the mg-api by no longer requiring the BGP ASN for querying the state... However, omicron never had plumbing to query BGP unnumbered interface state to begin with, which is what this PR remedies.

The new API endpoints are purely status, with no config whatsoever.

The maghemite hash currently points to the feature branch, but will be updated to main once this PR's counterpart lands.

@taspelund taspelund self-assigned this Jul 1, 2026
@taspelund taspelund added enhancement New feature or request. api Related to the API. networking Related to the networking. rust Pull requests that update rust code labels Jul 1, 2026
@taspelund

Copy link
Copy Markdown
Contributor Author

I have taken this through a4x2 manually and have validated that e2e connectivity works, as do the new endpoints:

trey@korgano 03:21:19 AM | ~/git/omicron ➦ d372d6342 ○
‣ curl -sS "${AUTH[@]}" "http://198.51.100.24/v1/system/networking/bgp/unnumbered/manager" | jq
[
  {
    "switch_slot": "switch0",
    "state": {
      "monitor_running": true,
      "pending_interfaces": [],
      "active_interfaces": [
        "qsfp0",
        "qsfp1"
      ]
    }
  },
  {
    "switch_slot": "switch1",
    "state": {
      "monitor_running": true,
      "pending_interfaces": [],
      "active_interfaces": [
        "qsfp1",
        "qsfp0"
      ]
    }
  }
]

trey@korgano 03:21:22 AM | ~/git/omicron ➦ d372d6342 ○
‣ curl -sS "${AUTH[@]}" "http://198.51.100.24/v1/system/networking/bgp/unnumbered/interfaces" | jq
[
  {
    "switch_slot": "switch0",
    "interface": {
      "interface": "qsfp0",
      "local_address": "fe80::aa40:25ff:fe25:9735",
      "router_lifetime": 1800,
      "discovered_peer": {
        "address": "fe80::aa40:25ff:fe00:9",
        "discovered_at": "2026-07-01T00:28:26Z",
        "last_advertisement": "2026-07-01T03:23:15Z",
        "router_lifetime": 30,
        "reachable_time": 0,
        "retrans_timer": 0,
        "expired": false,
        "time_until_expiry": "9s 61ms"
      },
      "ndp_state": {
        "tx": true,
        "rx": true
      }
    }
  },
  {
    "switch_slot": "switch0",
    "interface": {
      "interface": "qsfp1",
      "local_address": "fe80::aa40:25ff:fe25:9739",
      "router_lifetime": 1800,
      "discovered_peer": {
        "address": "fe80::aa40:25ff:fe00:11",
        "discovered_at": "2026-07-01T00:28:26Z",
        "last_advertisement": "2026-07-01T03:23:15Z",
        "router_lifetime": 30,
        "reachable_time": 0,
        "retrans_timer": 0,
        "expired": false,
        "time_until_expiry": "9s 398ms"
      },
      "ndp_state": {
        "tx": true,
        "rx": true
      }
    }
  },
  {
    "switch_slot": "switch1",
    "interface": {
      "interface": "qsfp1",
      "local_address": "fe80::aa40:25ff:fe4f:cc12",
      "router_lifetime": 1800,
      "discovered_peer": {
        "address": "fe80::aa40:25ff:fe00:12",
        "discovered_at": "2026-07-01T00:24:15Z",
        "last_advertisement": "2026-07-01T03:23:16Z",
        "router_lifetime": 30,
        "reachable_time": 0,
        "retrans_timer": 0,
        "expired": false,
        "time_until_expiry": "9s 550ms"
      },
      "ndp_state": {
        "tx": true,
        "rx": true
      }
    }
  },
  {
    "switch_slot": "switch1",
    "interface": {
      "interface": "qsfp0",
      "local_address": "fe80::aa40:25ff:fe4f:cc0e",
      "router_lifetime": 1800,
      "discovered_peer": {
        "address": "fe80::aa40:25ff:fe00:10",
        "discovered_at": "2026-07-01T00:24:15Z",
        "last_advertisement": "2026-07-01T03:23:16Z",
        "router_lifetime": 30,
        "reachable_time": 0,
        "retrans_timer": 0,
        "expired": false,
        "time_until_expiry": "9s 549ms"
      },
      "ndp_state": {
        "tx": true,
        "rx": true
      }
    }
  }
]

trey@korgano 03:24:15 AM | ~/git/omicron ➦ d372d6342 ○
‣ curl -sS "${AUTH[@]}" "http://198.51.100.24/v1/system/networking/bgp/unnumbered/interfaces/switch0/qsfp0" | jq
{
  "switch_slot": "switch0",
  "interface": {
    "interface": "qsfp0",
    "local_address": "fe80::aa40:25ff:fe25:9735",
    "router_lifetime": 1800,
    "discovered_peer": {
      "address": "fe80::aa40:25ff:fe00:9",
      "discovered_at": "2026-07-01T00:28:26Z",
      "last_advertisement": "2026-07-01T03:27:15Z",
      "router_lifetime": 30,
      "reachable_time": 0,
      "retrans_timer": 0,
      "expired": false,
      "time_until_expiry": "9s 260ms"
    },
    "ndp_state": {
      "tx": true,
      "rx": true
    }
  }
}

Comment thread nexus/src/app/unnumbered.rs Outdated
Comment thread nexus/src/app/unnumbered.rs Outdated
impl super::Nexus {
pub async fn bgp_unnumbered_manager_status(
&self,
_optctx: &OpContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slight typo here (and in the other methods):

Suggested change
_optctx: &OpContext,
_opctx: &OpContext,

That said, I'm not sure these methods should take an argument they don't use. I don't work in the auth area much, but my intuition is that auth checks should be performed at the "lowest level", instead of relying on callers performing them - should we be doing an auth check in this method (and therefore using the opctx arg after all)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the right approach is here tbh. I followed the approach that the existing bfd methods used as an example since I wasn't sure what to do for authz

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@david-crespo not to single you out, but since you've already chimed in... Do you have any guidance around what the right thing to do is with authz and opctx here?

let mut result = Vec::new();
for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] {
// Log an error if we only have one scrimlet, but keep going.
// We still want to return anything we're able to collect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not guaranteed to have any clients here; from the docs on resolve_mgd_clients():

/// # Errors
///
/// Fails if we cannot resolve MGD in DNS.
///
/// For any MGD instance we resolve via DNS, if the MGD instance does not know
/// its own switch slot, the switch slot -> client mapping for that instance
/// will be omitted from the returned map. Callers must not assume an `Ok(_)`
/// return value contains any client.

As written, that means if we get back an empty map from self.mg_clients(), we'll return an Ok(Vec::new()) result. Is that okay? I wonder if instead of returning a Vec, we should return something like a BTreeMap<SwitchSlot, UnnumberedManagerStateOrError>? Then we could return something logically like

{ "switch0": "no client found: blah blah error", "switch1": "SomeValidStatus" }

let mut result = Vec::new();
for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] {
// Log an error if we only have one scrimlet, but keep going.
// We still want to return anything we're able to collect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note here about maybe not having clients, and maybe returning a map keyed by switch slot so we can give back partial success?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially thought about returning a map keyed on switch slot, but it looked like other endpoints for switch-specific queries (e.g. lldp) all used the approach of returning a vec of wrapper structs that have switch slot as a member.

If we do go with a map, I think we could get rid of the wrapper structs SwitchUnnumberedManagerState and SwitchUnnumberedInterface since all they do for UnnumberedManagerState and UnnumberedInterface is embed the switch slot.

I don't work in this codebase very often, so I'm happy to go whichever way is preferable to the folks who live here


fn nexus_interface_name(interface: String) -> String {
if let Some(interface) = interface
.strip_prefix("tfport")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method makes me nervous and worried we're baking knowledge into nexus that really doesn't belong there. Some questions:

  • If we expect all interfaces to be named tfport{X}_0, should we fail instead of returning the interface unchanged?
  • Is this a sign we're overusing Strings and instead should have something like a TfPort(String) type, with methods for formatting itself either with or without the tfport ... _0 trappings?
  • Are external API callers expected to use a tfport{X}_0 name?
  • I assume "tf" is from "tofino" - are we setting ourselves up for future pain whenever we have non-tofino switch?

Maybe most of this is a nonissue since this function is private and only used for API version conversions (or maybe it's even addressing some of these questions as we move the API forward)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we expect all interfaces to be named tfport{X}_0, should we fail instead of returning the interface unchanged?

I'm not sure on this front. Today mgd takes a String as its query parameter and does a lookup for an interface with that exact name, without any transforms applied -- which is the same logic that's used for BGP unnumbered peers. So from the mgd side, if the String given to it matches a real interface then it'll return state for that same interface. I'm not sure how mgd would take String A and return String B or how we'd want that to appear... I'd think it'd be better to return whatever we got so we can at least still get some operational state, even if the ifname is mangled.

Is this a sign we're overusing Strings and instead should have something like a TfPort(String) type, with methods for formatting itself either with or without the tfport ... _0 trappings?
I assume "tf" is from "tofino" - are we setting ourselves up for future pain whenever we have non-tofino switch?

I would much prefer a new TfPort type that organizes all of this logic, but I don't know how toronto is going to affect things... I imagine it's probably too early to tell since I don't think we have the equivalent of the tfport driver for toronto yet. If we go through the effort to build a type like that, I would almost prefer that it lives someplace like oxnet so it can be reused by mgd, dpd, tfportd, and nexus.

Are external API callers expected to use a tfport{X}_0 name?

No, external API users supply a name like "qsfp0". I added the tfport formatting logic for this endpoint because the other link/port/peer APIs expect the bare name, not the tfport name. No need to expose the internal implementation detail if we can help it.

Maybe most of this is a nonissue since this function is private and only used for API version conversions (or maybe it's even addressing some of these questions as we move the API forward)?

I think today it's a non-issue, but I would definitely like to have more structure in place around these conversions. I just think it's going to require more coordination across the network component repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote for using a newType (like what I mentioned above, but yeah TfPort is a better name).

Right now we're codifying an expectation in a way that would be better expressed with types. If and when we need to change that expectation we'll be better served by already having the type in place, be that for adapting to Toronto or to an oxnet type.

Also, we don't need to expose the type on the API if we are concerned about breaking changes. I'd be for a type here even if it's entirely confined to this file.

Comment thread nexus/types/versions/src/bgp_unnumbered_status/networking.rs Outdated
Comment on lines +146 to +150
/// When the peer was first discovered (ISO 8601 timestamp)
pub discovered_at: String,
/// When the most recent Router Advertisement was received (ISO 8601
/// timestamp)
pub last_advertisement: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these be DateTime<Utc> instead of String?

If they're coming from a remote source and we're worried about parsing, it's maybe worth noting that in the doc comment. "Expected to be ISO 8601 timestamps, but presented as-is from the remote router" or something like that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are all generated by mgd directly, not derived from an external source. there's no reason this can't be done cleaner -- I'll fix on the maghemite side and omicron can just inherit it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for DateTime<Utc>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm actually going to change the return type to use a Duration rather than an absolute timestamp.
This makes it simpler for reporting during startup (no NTP) and gives a view more inline with how the underlying component actually thinks about the world (using a monotonic Instant to calculate a Duration and comparing it against an expiry timeout).

@taspelund
taspelund force-pushed the trey/bgp-unnumbered-status branch from 654fe27 to 5e1011e Compare July 6, 2026 21:22
/// Peer IPv6 address
pub address: Ipv6Addr,
/// When the peer was first discovered (ISO 8601 timestamp)
pub discovered_at: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convention we try to follow with timestamps, though we don't do it universally, is time_*. This one is easy because it can be time_discovered. last_advertisement is a little weirder, would have to be something like time_last_advertised — not terrible but not great.

networking_bgp_status GET /v1/system/networking/bgp-status
networking_bgp_unnumbered_interface_list GET /v1/system/networking/bgp/unnumbered/interfaces
networking_bgp_unnumbered_interface_view GET /v1/system/networking/bgp/unnumbered/interfaces/{switch_slot}/{interface_name}
networking_bgp_unnumbered_manager_status GET /v1/system/networking/bgp/unnumbered/manager

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that we have a great pattern here, but these stand out in being the only ones to nest something under bgp. Maybe all of them should look like /bgp/*, but for now, for consistency maybe it should be /v1/system/networking/bgp-unnumbered-interfaces?

networking_bfd_disable POST /v1/system/networking/bfd-disable
networking_bfd_enable POST /v1/system/networking/bfd-enable
networking_bfd_status GET /v1/system/networking/bfd-status
networking_bgp_announce_set_delete DELETE /v1/system/networking/bgp-announce-set/{announce_set}
networking_bgp_announce_set_list GET /v1/system/networking/bgp-announce-set
networking_bgp_announce_set_update PUT /v1/system/networking/bgp-announce-set
networking_bgp_announcement_list GET /v1/system/networking/bgp-announce-set/{announce_set}/announcement
networking_bgp_config_create POST /v1/system/networking/bgp
networking_bgp_config_delete DELETE /v1/system/networking/bgp
networking_bgp_config_list GET /v1/system/networking/bgp
networking_bgp_config_update PUT /v1/system/networking/bgp
networking_bgp_exported GET /v1/system/networking/bgp-exported
networking_bgp_imported GET /v1/system/networking/bgp-imported
networking_bgp_message_history GET /v1/system/networking/bgp-message-history
networking_bgp_status GET /v1/system/networking/bgp-status
networking_bgp_unnumbered_interface_list GET /v1/system/networking/bgp/unnumbered/interfaces
networking_bgp_unnumbered_interface_view GET /v1/system/networking/bgp/unnumbered/interfaces/{switch_slot}/{interface_name}
networking_bgp_unnumbered_manager_status GET /v1/system/networking/bgp/unnumbered/manager

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can shift over to that convention for consistency. Thanks!

Comment thread nexus/src/external_api/http_entrypoints.rs
Comment on lines +13 to +15
fn maghemite_interface_name(interface_name: &str) -> String {
format!("tfport{interface_name}_0")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great place to use a newType to codify the format expectations of the String we're carrying around. It could look like this:

pub(crate) struct MaghemiteIfaceName(String);
impl MaghemiteIfaceName {
    pub fn from_interface_name(iface_name: &str) -> Self {
        Self(format!("tfport{iface_name}_0"))
    }
}
impl AsRef<str> for MaghemiteIfaceName {
    fn as_ref(&self) -> &str {
        &self.0
    }
}
impl std::fmt::Display for MaghemiteIfaceName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}
  • A semantically-explicit constructor name is better if we're not typing the input as well. That could be good if we want to enforce other things like maximum size and absence of certain special characters. The sooner we find these inconsistencies the better.


fn nexus_interface_name(interface: String) -> String {
if let Some(interface) = interface
.strip_prefix("tfport")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote for using a newType (like what I mentioned above, but yeah TfPort is a better name).

Right now we're codifying an expectation in a way that would be better expressed with types. If and when we need to change that expectation we'll be better served by already having the type in place, be that for adapting to Toronto or to an oxnet type.

Also, we don't need to expose the type on the API if we are concerned about breaking changes. I'd be for a type here even if it's entirely confined to this file.

Comment on lines +24 to +26
} else {
interface
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it's good to go on with a silent error here.

Comment on lines +146 to +150
/// When the peer was first discovered (ISO 8601 timestamp)
pub discovered_at: String,
/// When the most recent Router Advertisement was received (ISO 8601
/// timestamp)
pub last_advertisement: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for DateTime<Utc>

Comment thread nexus/types/versions/src/bgp_unnumbered_status/networking.rs Outdated
taspelund added a commit to oxidecomputer/maghemite that referenced this pull request Jul 7, 2026
Replace the stringly discovered-peer API type with typed values, in
support of oxidecomputer/omicron#10705:

- DiscoveredRouter (v12) now reports relative durations
  (time_since_discovered, time_since_last_rx, effective_reachable_time)
  instead of ISO 8601 timestamp strings reconstructed from Instants at
  query time (a derivation that was unstable across queries and shifted
  under clock adjustments), and drops the expired bool and
  human-readable time_until_expiry string entirely. An expired entry is
  represented as discovered_peer: None, making the
  expired-but-still-reported state unrepresentable.
- time-until-expiry is derived, not transmitted: a
  DiscoveredRouter::time_until_expiration() helper lives with the API
  type, and mg-admin-client replaces the generated DiscoveredRouter
  with the real type so mgadm shares it.
- The v5 NDP downgrade reconstructs the legacy wall-clock timestamps
  and human-readable expiry string from the durations. Expired peers
  are no longer visible through v5 (its only consumer is mgadm, which
  moves to the new endpoint).
- State-to-API conversion is now From<&DiscoveredRouterState> for
  Option<DiscoveredRouter> in the unnumbered crate, with tests for the
  expired -> None and live-mapping paths.
- format_duration_human moves from mg-common to client-common (zero-dep
  CLI utility crate), so mg-api-types-versions and the admin CLIs share
  one copy without ddmadm depending on mg-api-types.
- mgadm drops the Reachable column (peer presence now implies
  reachability) and shows durations for the discovered peer detail.
- DiscoveredRouterState renames `when` to `last_seen`; dead
  validate_peer_for_interface removed.

Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
taspelund added 5 commits July 7, 2026 18:31
Now that maghemite has migrated to the RFD 619 convention, it exposes
most types via the mg-api-types crate. This crate should have its hash
updated alongside mg-admin-client and ddm-admin-client.

Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
Adds external API endpoints to GET the BGP unnumbered status of
interfaces and the unnumbered manager. This provides insights into the
Router Discovery state (have we learned about a BGP peer via NDP) and
the readiness of an interface that's been configured for BGP unnumbered
(does the interface exist in the OS? does it have an IPv6 link-local IP?
or is it active?). These are all important when diagnosing failures or
delays in the establishment of BGP unnumbered peers, since these are all
prerequisites for the BGP FSM to complete connections.

This is the omicron side of maghemite#800

Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
@taspelund
taspelund force-pushed the trey/bgp-unnumbered-status branch from 5e1011e to c69cc2c Compare July 8, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the API. enhancement New feature or request. networking Related to the networking. rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants