Inspect and configure Linux devlink devices.
let (connection, handle) = devlink::new_connection()?;
tokio::spawn(connection);
for device in handle.devices().await? {
let info = handle.info(&device.handle()).await?;
println!("{} {:?}", device.handle(), info.running);
}devlink is a generic netlink family, so the family id is resolved at runtime.
Wire format lives in
netlink-packet-devlink.
| API | devlink(8) equivalent |
Privileges |
|---|---|---|
handle.devices() |
devlink dev show |
none |
handle.info(&h) |
devlink dev info |
CAP_NET_ADMIN |
handle.eswitch(&h) |
devlink dev eswitch show |
CAP_NET_ADMIN |
handle.set_eswitch(&h, ..) |
devlink dev eswitch set |
CAP_NET_ADMIN |
handle.params(&h) |
devlink dev param show |
CAP_NET_ADMIN |
handle.set_param(&h, ..) |
devlink dev param set |
CAP_NET_ADMIN |
handle.ports(..) |
devlink port show |
none |
handle.health_reporters(..) |
devlink health show |
CAP_NET_ADMIN |
handle.reload(&h, ..) |
devlink dev reload |
CAP_NET_ADMIN |
handle.set_port_function(&p, ..) |
devlink port function set |
CAP_NET_ADMIN |
handle.rates(..) |
devlink port function rate show |
CAP_NET_ADMIN |
handle.set_rate(&a, ..) |
devlink port function rate set |
CAP_NET_ADMIN |
handle.add_rate_node(..) |
devlink port function rate add |
CAP_NET_ADMIN |
handle.del_rate_node(..) |
devlink port function rate del |
CAP_NET_ADMIN |
handle.health_recover(..) |
devlink health recover |
CAP_NET_ADMIN |
handle.set_health_reporter(..) |
devlink health set |
CAP_NET_ADMIN |
handle.health_diagnose(..) |
devlink health diagnose |
CAP_NET_ADMIN |
handle.health_dump(..) |
devlink health dump show |
CAP_NET_ADMIN |
handle.clear_health_dump(..) |
devlink health dump clear |
CAP_NET_ADMIN |
handle.health_test(..) |
devlink health test |
CAP_NET_ADMIN |
handle.flash_update(..) |
devlink dev flash |
CAP_NET_ADMIN |
handle.traps(..) |
devlink trap show |
CAP_NET_ADMIN |
handle.set_trap(..) |
devlink trap set |
CAP_NET_ADMIN |
handle.trap_groups(..) |
devlink trap group show |
CAP_NET_ADMIN |
handle.set_trap_group(..) |
devlink trap group set |
CAP_NET_ADMIN |
handle.trap_policers(..) |
devlink trap policer show |
CAP_NET_ADMIN |
handle.set_trap_policer(..) |
devlink trap policer set |
CAP_NET_ADMIN |
new_monitor_connection() |
devlink monitor |
none |
handle.set_notify_filter(..) |
(no equivalent) | none |
Verified against a ConnectX-6, an Intel i40e and netdevsim, field for field
against devlink(8).
use devlink::{EswitchChange, EswitchMode};
handle.set_eswitch(&h, EswitchChange::mode(EswitchMode::SwitchDev)).await?;Only the fields set in EswitchChange are sent, so this does not clobber
settings you did not ask about.
Changing mode is disruptive. The driver tears down and rebuilds its
netdevices, and on mlx5 switchdev creates a representor per VF. A verified
side effect: the device's RDMA index changes too, because the eswitch flip
re-registers the ib_device — mlx5_0 went from index 0 to index 12 across
one round trip. Anything caching RDMA device indices needs to re-resolve.
cargo run --example eswitch_switchdev -- pci/0000:02:00.0 does a full
legacy → switchdev → legacy cycle, restoring the original mode on every exit
path including failures.
use devlink::{ParamCmode, ParamValueInput};
handle.set_param(&h, "enable_roce", ParamCmode::Driverinit,
ParamValueInput::Bool(true)).await?;ParamValueInput exists because the wire encoding has rules that are easy to
violate and produce a bare EINVAL:
- integers must be sent at their exact width — a
u32parameter rejects a 4-byte value sent as anything else - strings must be NUL-terminated inside the attribute
- a boolean is a flag: present with zero length means true, absent means false. Sending a bool with a one-byte payload is rejected.
The same asymmetry applies when reading, and it bit us: ParamValue::as_bool
returns bool, not Option<bool>, because the kernel only emits
PARAM_VALUE_DATA for a true boolean. Treating the missing attribute as
"unknown" rather than false made esw_multiport look unreadable.
A driverinit value does not take effect until the driver reloads.
cargo run --example param_roundtrip -- pci/0000:02:00.0 reads every parameter
and writes it back unchanged, exercising the full set path across all three
cmodes and every value type without altering the device:
pci/0000:02:00.0: 17 parameter(s)
max_macs Driverinit = 128 (written back ok)
enable_roce Driverinit = true (written back ok)
flow_steering_mode Runtime = smfs (written back ok)
esw_multiport Runtime = false (written back ok)
cqe_compress_type Permanent = balanced (written back ok)
17 written back, 0 skipped
0 parameter(s) differ from before -- expected 0
use devlink::ReloadAction;
let result = handle.reload(&h, ReloadAction::DriverReinit, None).await?;
println!("{:?}", result.actions_performed); // [DriverReinit]This is what makes a driverinit parameter take effect.
Disruptive. DriverReinit tears down and re-instantiates the driver's
entities. Verified on a ConnectX-6: netdevices go away and come back, and the
device's RDMA index moved from 12 to 13 across one reload. Anything caching
netdev or RDMA indices must re-resolve afterwards.
Two things the API handles for you:
- The action is always sent, even though the kernel defaults to
DriverReinit.devlink_nl_reload_doitonly replies withRELOAD_ACTIONS_PERFORMEDwhen the request named an action or a limit — omit it and you get a bare ack with no way to tell what happened. - A limit is a
struct nla_bitfield32, eight bytes: avalueand aselector, and a bit only counts when set in both. It is not au32of flags; sending four bytes getsEINVAL. The bit asserted is the enum value's position —DEVLINK_RELOAD_LIMIT_NO_RESETis1, so the wire bit isBIT(1)= 2.Bitfield32::setandbit_ofdo this correctly.
The sequence a control plane needs is set → reload → verify, because a
driverinit value is not consumed until the driver reinitialises:
pci/0000:02:00.0 enable_roce (driverinit)
original value: true
setting to false ...
reads back as: Some(false)
reloading ...
actions performed: [DriverReinit]
after reload: Some(false)
value survived the reload
cargo run --example param_reload_verify -- <handle> <param> does exactly
that, then restores and reloads again.
reload_into(.., Some(Netns::Pid(pid))) moves the device. This is the only
way to put an mlx5 RDMA device in a namespace:
devlink_reload_netns_change rewrites the devlink instance's net between
reload_down and reload_up, and mlx5_ib then allocates its ib_device with
ib_alloc_device_with_net(mlx5_ib_dev, ib_dev, mlx5_core_net(mdev));
/* mlx5_core_net(dev) == devlink_net(priv_to_devlink(dev)) */mlx5 is the only driver in the tree wired up this way — which is why
ib_alloc_device_with_net exists at all.
Whether the RDMA device follows depends entirely on
ib_core.netns_mode. _ib_alloc_device discards the requested net while
the system is in shared mode:
net = ib_devices_shared_netns ? &init_net : net;Both measured on the same hardware, same code, different boot parameter:
| devlink instance | RDMA device | |
|---|---|---|
default (netns_mode=1, shared) |
moves | stays in init_net |
ib_core.netns_mode=0 (exclusive) |
moves | moves with it |
With exclusive mode:
BEFORE init_net: devlink 02:00.0: 1, rdma mlx5_0: 1
AFTER init_net: devlink 02:00.0: 0, rdma mlx5_0: 0 <- both left
AFTER target ns: devlink 02:00.0: 1, rdma mlx5_0: 1 <- both arrived
So a devlink reload is a complete, working way to place an mlx5 RDMA device in
a namespace — and it is the better of the two routes, because it sidesteps
ib_device_set_netns_put's disassociate_ucontext requirement entirely by
destroying and re-creating the device rather than migrating it. Both routes
still need the boot parameter. See the rdma crate's README.
let ports = handle.ports(Some(&h)).await?; // or None for all
let reporters = handle.health_reporters(Some(&h)).await?;Note where mlx5 puts things: the PCI device has no ports, they belong to the auxiliary device.
pci/0000:02:00.0 0 port(s), reporters: fw, fw_fatal, vnic
auxiliary/mlx5_core.eth.0 1 port (65535, physical, eth, enp2s0f0np0)
reporters: tx, rx
Both match devlink port show and devlink health show exactly. An empty
port list for a PCI handle is a correct answer, not a bug.
It looks like an ordinary boolean parameter, but mlx5 implements it as a live view of LAG state:
static int mlx5_devlink_esw_multiport_get(...)
{ ctx->val.vbool = mlx5_lag_is_mpesw(dev); return 0; }
static int mlx5_devlink_esw_multiport_set(...)
{ ... return mlx5_lag_mpesw_enable(dev); }and mlx5_lag_mpesw_queue_work opens with
struct mlx5_lag *ldev = mlx5_lag_dev(dev);
if (!ldev)
return 0; /* success, having done nothing */So the set returns success and silently does nothing when the card has no LAG device — confirmed here with both PFs in switchdev mode:
setting pci/0000:02:00.0 to switchdev ... now Some(SwitchDev)
setting pci/0000:02:00.1 to switchdev ... now Some(SwitchDev)
enabling esw_multiport on pci/0000:02:00.0 ...
set returned ok, reads back as Some(false)
Always read back after setting this one — Ok(()) tells you nothing.
The natural assumption is that the ports must be operationally up. The code
says otherwise — mlx5_lag_enable_mpesw gates on:
if (mlx5_eswitch_mode(dev0) != MLX5_ESWITCH_OFFLOADS ||
!MLX5_CAP_PORT_SELECTION(dev0, port_select_flow_table) ||
!MLX5_CAP_GEN(dev0, create_lag_when_not_master_up) ||
!mlx5_lag_check_prereq(ldev) ||
!mlx5_lag_shared_fdb_supported(ldev))
return -EOPNOTSUPP;No link state anywhere, and one of the capabilities is literally named
create_lag_when_not_master_up. The carrier requirement people hit belongs to
bond-driven LAG, where mlx5_lag_do_bond reacts to netdev events — a
different entry point.
The real blocker is upstream of both: mlx5_lag_mpesw_queue_work returns 0 if
mlx5_lag_dev(dev) is NULL. Measured on a BlueField-3 B3240 (SEPARATED_HOST,
LAG_RESOURCE_ALLOCATION=PRE_ALLOCATION, both ports ETH), with both PFs in
switchdev and both netdevs enslaved to an 802.3ad bond: mlx5's LAG layer
emitted no dmesg at all and esw_multiport stayed false. No LAG device
exists, so neither loopback nor cabling would change the outcome — the
question is why mlx5_lag_is_supported() declines, which needs a capability
dump this toolchain cannot produce.
IPsec offload is not a devlink parameter, despite looking like one. It is a
capability bit inside DEVLINK_ATTR_PORT_FUNCTION:
use devlink::{FnCaps, PortFunctionChange};
handle.set_port_function(&port, PortFunctionChange::cap(
FnCaps { ipsec_packet: true, ..Default::default() },
true,
)).await?;Three traps here, all of which cost a debugging round:
- The nested set has its own attribute namespace.
HW_ADDRis 1 andSTATEis 2 — the same numbers asBUS_NAMEandDEV_NAMEin the main space. The kernel parses it withdevlink_function_nl_policy, so it gets its ownPortFunctionAttrtype. Most devlink nests are not like this. PORT_FUNCTIONneedsNLA_F_NESTED.devlink(8)sendsnla_type=NLA_F_NESTED|0x91; without the flag the kernel answersEINVALwith no hint.- Select only the capability you are changing. Caps are a bitfield32 and
the kernel dispatches per selected bit, so naming one the device does not
implement fails the whole request — even when the value you asked for is
the one it already has.
PortFunctionChange::capselects exactly one.
Port functions only exist on VF/SF ports, which only exist in switchdev mode. A physical port reports no function; that is a correct answer.
Verified on a BlueField-3: roce toggles off and back on, and ipsec_packet
returns EOPNOTSUPP — the same answer devlink(8) gives, since this device
does not implement packet-mode IPsec.
use devlink::{RateAddr, RateChange};
handle.add_rate_node(&h, "tenant_a",
RateChange::default().tx_share(500_000_000).tx_max(1_000_000_000)).await?;
handle.set_rate(&RateAddr::Node { handle: h.clone(), name: "tenant_a".into() },
RateChange::default().tx_max(2_000_000_000)).await?;Rates are in bytes per second. A leaf is addressed by port index and a node
by name — sending the wrong one gets EINVAL, so RateAddr keeps them
distinct.
Like port functions, rate objects only appear in switchdev mode. Verified: two leaves discovered for two VF ports, a node created, retuned and deleted.
use devlink::{TrapAction, TrapGroupChange, TrapPolicerChange};
handle.set_trap(&h, "blackhole_route", TrapAction::Trap).await?;
handle.set_trap_group(&h, "l2_drops",
TrapGroupChange::default().action(TrapAction::Drop).policer(1)).await?;
handle.set_trap_policer(&h, 1,
TrapPolicerChange::default().rate(2000).burst(128)).await?;A trap names a reason the hardware may punt, drop or mirror a packet. Groups exist so one action or policer applies to many traps; a policer is a token bucket limiting how much trapped traffic reaches the CPU.
Traps are a switch-ASIC feature — mlxsw, prestera, netdevsim. Most NICs
register none, and an empty list is a correct answer. This mlx5 registers two.
The kernel refuses to change the action of a non-drop trap, because dropping an exception or control trap would break forwarding or the control plane. It reports that refusal by returning 0 with an extack message:
if (trap_item->action != trap_action &&
trap_item->trap->type != DEVLINK_TRAP_TYPE_DROP) {
NL_SET_ERR_MSG(extack, "Cannot change action of non-drop traps. Skipping");
return 0;
}Measured on netdevsim, both through this crate and devlink(8):
drop trap source_mac_is_multicast: drop -> trap
reads back as: trap <- took effect
restored to: drop
exception trap fid_miss: asking for drop
request returned Ok
reads back as: trap
UNCHANGED -- the kernel skips non-drop traps and still reports success
devlink(8) prints Warning: Cannot change action of non-drop traps. Skipping. and also exits 0. It can say that because it reads netlink
extack, which netlink-packet-core 0.8 does not model at all — ErrorMessage
carries only a code and the original header, and nothing sets NETLINK_EXT_ACK
on the socket. So this crate cannot surface the reason, only the silence.
Check [Trap::trap_type] before calling, or read the trap back. This is the
same shape as esw_multiport: Ok(()) is not confirmation.
It is how devlink spells "none". Verified against the kernel both ways:
$ devlink trap policer set netdevsim/netdevsim1 policer 0 rate 1000
Error: Device did not register this trap policer. # ENOENT
$ devlink trap group set netdevsim/netdevsim1 group l3_drops policer 0
$ devlink trap group show netdevsim/netdevsim1 group l3_drops
name l3_drops generic true # unbound, not bound
So 0 can never name a policer — it is reserved by construction. That fact
lives in no_policer(), which names the intent at the call site, rather than
in the type:
TrapGroupChange::default() // policer: None -> untouched
TrapGroupChange::default().policer(id) // Some(id) -> bound
TrapGroupChange::default().no_policer() // Some(0) -> unboundOption<NonZeroU32> was tried here and reverted. It defended a case the
kernel does not produce — TRAP_POLICER_ID is only emitted when a policer is
actually bound, so Some(0) never arrives — and the compiler flagged four
call sites, none of which was a bug. What it did buy was an asymmetry: the
read side could use Option<NonZeroU32> but the write side has three states,
not two, so it needed a separate PolicerBinding enum. That made the crate
answer one question two ways, when RateChange::parent had already answered
it once: an empty parent name means "detach", and .parent() / .no_parent()
carry the meaning. .policer() / .no_policer() now match it.
Verified round trip:
group l2_drops: no policer bound
bound policer 1 -> Some(1)
no_policer() -> None
Against netdevsim (14 traps, 6 groups, 3 policers) and the mlx5 (2 traps,
1 group, no policers), field for field against devlink(8) — including
fid_miss generic false, the one row whose flag differs from every other and
so the canary for a decoder that defaults it to true.
A group action fans out, on both: set group action trap -> 2/2 now trap on
the mlx5, 1/1 on netdevsim, each restored afterwards.
Traps brought two more of the PORT_FUNCTION hazard, and these are worse
because they are numbered from zero:
| nest | child 0 | child 1 | child 2 |
|---|---|---|---|
STATS |
RX_PACKETS u64 |
RX_BYTES u64 |
RX_DROPPED u64 |
TRAP_METADATA |
IN_PORT flag |
FA_COOKIE flag |
— |
| main namespace | UNSPEC |
BUS_NAME string |
DEV_NAME string |
Decoding a stats nest with DevlinkAttr does not mislabel a field — it reads
eight bytes of counter as a NUL-terminated string. They get their own types,
StatsAttr and TrapMetadataAttr, and a test asserts the misread really would
happen so the reason for the split does not get lost.
cargo run --example traps -- [handle] [--go].
let (connection, handle, messages) = devlink::new_monitor_connection().await?;
tokio::spawn(connection);
let mut notifications = devlink::notifications(messages);
while let Some(notification) = notifications.next().await {
println!("{notification}");
}Every devlink change is published on one multicast group, DEVLINK_MCGRP_CONFIG
(named "config"). Two things make subscribing harder than it looks:
- The group has no fixed number. devlink is a generic netlink family, so
the group id is allocated at registration and has to be resolved by name,
like the family id.
new_monitor_connectionisasyncfor this reason — it does a round trip before it can subscribe. netlink-protonever binds the socket. It relies on the kernel binding implicitly on the firstsendto. An unbound socket is not in the kernel's listener tables, so it receives no multicast however many groups it joins —add_membershipalone is silently useless. The same bug bit therdmacrate's monitor. Bind first, then join.
Verified against devlink(8) by churning a netdevsim instance: 68
notifications, 68 notifications, same objects in the same order — device,
parameters, region, three trap policers, six trap groups, fourteen traps,
ports, then the whole thing torn down in reverse.
[netdevsim/netdevsim1] dev
[netdevsim/netdevsim1] param max_macs
[netdevsim/netdevsim1] region dummy
[netdevsim/netdevsim1] trap-policer 1
[netdevsim/netdevsim1] trap-group l2_drops
[netdevsim/netdevsim1] trap igmp_query action Mirror
[netdevsim/netdevsim1] port 1 netdev eni1np2
[del netdevsim/netdevsim1] trap source_mac_is_multicast action Drop
[del netdevsim/netdevsim1] dev
devlink dev param set with the value a parameter already holds succeeds and
notifies nothing. devlink(8) sees the same silence, so this is kernel
behaviour, not a decoding gap:
| write | notifications |
|---|---|
max_macs 128 → 64 → 128 |
2 |
max_macs 128 → 128 |
0 |
This is a stream of changes, not of writes. Do not use it to confirm a request was applied — read the value back.
handle.set_notify_filter(Some(&h), None).await?; // one device
handle.set_notify_filter(None, None).await?; // cleardevlink(8) has no equivalent — its monitor receives everything and discards
the rest in userspace. DEVLINK_CMD_NOTIFY_FILTER_SET (kernel 6.9+) attaches
the filter to the socket, so the process is never woken at all. Measured over
the same netdevsim churn:
| socket | notifications |
|---|---|
| unfiltered | 68 |
filtered to netdevsim/netdevsim1 |
34 (the half after the filter was set) |
filtered to pci/0000:02:00.0 |
0 |
The filter is per-socket kernel state, which is why it has to be set through
the handle that new_monitor_connection returns rather than an ordinary one.
Older kernels answer EOPNOTSUPP; listening unfiltered is still correct.
cargo run --example monitor -- [handle].
for reporter in handle.health_reporters(None).await? {
let tree = handle.health_diagnose(&reporter.addr()).await?;
println!("{tree}");
}DEVLINK_ATTR_FMSG is a flat token stream that reconstructs into a tree,
and one NEST_END closes any kind of nest, so decoding it is a parser rather
than a fold:
FmsgObjNestStart
FmsgPairNestStart
FmsgObjName("Syndrome")
FmsgObjValueType(U8) FmsgObjValueData([0])
FmsgNestEnd
FmsgNestEnd
A pair's value can itself be an object or an array, which is what makes it
recursive. [Fmsg] is the resulting tree.
This one cost a debugging round and is the reason fw worked while tx did
not. A value is two nla_put calls, a type then a data. When the type fits
the remaining skb and the data does not, the kernel writes the type, fails, and
re-emits the whole item in the next message -- leaving an orphan behind:
1610 FmsgObjName("pc")
1611 FmsgObjValueType(U32) <- last token of message 3, no data follows
1612 FmsgObjValueType(U32) <- first token of message 4
1613 FmsgObjValueData([0, 0, 0, 0])
Measured on an mlx5 tx reporter: 8703 tokens across 17 messages, three of
which ended in an orphan. Small reporters never split, so fw and vnic
parse fine without handling it and the bug only appears once a diagnose is a
few kilobytes. The parser takes the last type of a run, since a real value has
exactly one.
Tokens must also be concatenated across messages before parsing, or a nest
that straddles a split never closes. Fmsg::from_messages does both.
Against an mlx5 ConnectX-6, cross-checked with devlink health diagnose:
=== pci/0000:02:00.0 vnic state Some(Healthy) errors 0 ===
vNIC env counters:
total_error_queues: 0
invalid_command: 1
icm_consumption: 34022
=== auxiliary/mlx5_core.eth.0/65535 tx state Some(Healthy) errors 0 ===
Common Config:
SQ:
stride size: 64
size: 1024
ts_format: FRC
Every value matches, checked by multiset rather than by eye -- 63 each of
sqn, cqn, eqn, irqn, txq ix and channel ix, identical to
devlink(8).
fw_fatal stores a firmware crdump: an array of ~3.7KB binary chunks,
several megabytes in total, spread over hundreds of messages. Display
truncates a long binary to 32 bytes and a length, because rendering it in full
turns a status line into 39MB of hex; the bytes stay in the Binary variant.
fw, fw_fatal and vnic belong to the device; tx and rx belong to a
port, and naming one without its index is rejected:
$ devlink health diagnose auxiliary/mlx5_core.eth.0 reporter tx
kernel answers: Invalid argument
ReporterAddr carries the scope and HealthReporter::addr() builds the right
one from a dump entry, so the distinction cannot be got wrong by accident.
for device in handle.devices().await? {
println!("{} -> {:?}", device.handle(), device.nested);
}pci/0000:02:00.0 -> [auxiliary/mlx5_core.eth.0]
The section on ports notes that mlx5 puts ports on the auxiliary device
rather than the PCI one. DEVLINK_ATTR_NESTED_DEVLINK is how to follow that
link programmatically instead of guessing the name.
Device::stats carries the reload counters devlink dev show prints, which
survive across reloads:
pci/0000:02:00.0 reloaded 2x action Some(DriverReinit) limit None
handle.flash_update(&h, FlashUpdate::new("firmware.bin")).await?;This writes to the device's flash, and a failed write can leave a card unbootable. It has deliberately never been pointed at real hardware here.
Two things that are easy to get wrong:
- The kernel opens the file, not this process.
file_nameis resolved byrequest_firmwareagainst the kernel's search path, which/sys/module/firmware_class/parameters/pathprepends to. A file that exists in your working directory is not found. - The ack does not arrive until the flash finishes -- minutes on real hardware. Progress is published as notifications, so pair this with the monitor rather than waiting blind.
netdevsim implements flash_update as a software stub with no firmware
behind it, so the entire protocol can be exercised at zero risk. Point the
kernel's firmware loader at a scratch directory, flash a file whose contents
are ignored, then put the path back:
flashing netdevsim/netdevsim1 with fake.bin ...
notify: [netdevsim/netdevsim1] flash started
notify: [netdevsim/netdevsim1] flash Preparing to flash
notify: [netdevsim/netdevsim1] flash Flashing 0/50000
... 51 progress notifications ...
notify: [netdevsim/netdevsim1] flash Flashing 50000/50000
notify: [netdevsim/netdevsim1] flash Flash select
notify: [netdevsim/netdevsim1] flash Flashing done
notify: [netdevsim/netdevsim1] flash finished
flash returned Ok
That covers the request encoding, all three status attributes, the notification decoding and the long-running-doit behaviour. What it does not cover is a real driver's flash implementation -- but that part is the driver's, not this crate's.
There is deliberately no flash example in this repository: an example
that writes firmware is an attractive nuisance.
Shared buffer (18 commands, 15 attributes), dpipe (4 commands and 36
attributes -- the largest remaining surface), linecards, regions, resources and
selftests. Probed against the hardware here, every one of them is either
EOPNOTSUPP or an empty list: they are switch-ASIC and modular-chassis
features. Those groups are 63 of the 93 unmodelled attributes, so coverage for
NIC-class devices is considerably more complete than the raw count suggests.
DEVLINK_CMD_PORT_PARAM_* (4 commands) is dead rather than deferred: no driver
registers port parameters, the dump returns nothing, and iproute2 has no
command for them.
Netlink extack is the one gap that cuts across everything already here.
The kernel attaches a human-readable reason to many refusals — including the
two "returns success, does nothing" cases this crate has hit — and
netlink-packet-core 0.8 models none of it. Adding it means setting
NETLINK_EXT_ACK on the socket, parsing NLMSGERR_ATTR_MSG out of the ack,
and threading it through Handle::request's return, so it belongs upstream
rather than here.