From da65d55e5d002f74babe029c0773100e6b398c34 Mon Sep 17 00:00:00 2001 From: Anthony Harivel Date: Mon, 9 Feb 2026 09:49:30 +0100 Subject: [PATCH 1/2] Bump github.com/jsimonetti/rtnetlink/v2 from 2.1.0 to 2.2.0 Bumps [github.com/jsimonetti/rtnetlink/v2](https://github.com/jsimonetti/rtnetlink) from 2.1.0 to 2.2.0. Signed-off-by: Anthony Harivel --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 056a340030..b70e1edbf6 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/hashicorp/go-envparse v0.1.0 github.com/hodgesds/perf-utils v0.7.0 github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 - github.com/jsimonetti/rtnetlink/v2 v2.1.0 + github.com/jsimonetti/rtnetlink/v2 v2.2.0 github.com/lufia/iostat v1.2.1 github.com/mattn/go-xmlrpc v0.0.3 github.com/mdlayher/ethtool v0.5.0 diff --git a/go.sum b/go.sum index 1ef3b5a97a..20e615f5da 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao= -github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY= +github.com/cilium/ebpf v0.20.0 h1:atwWj9d3NffHyPZzVlx3hmw1on5CLe9eljR8VuHTwhM= +github.com/cilium/ebpf v0.20.0/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -42,8 +42,8 @@ github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:PoK3ejP github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/jsimonetti/rtnetlink/v2 v2.1.0 h1:3sSPD0k+Qvia3wbv6kZXCN0Dlz6Swv7RHjvvonuOcKE= -github.com/jsimonetti/rtnetlink/v2 v2.1.0/go.mod h1:hPPUTE+ekH3HD+zCEGAGLxzFY9HrJCyD1aN7JJ3SHIY= +github.com/jsimonetti/rtnetlink/v2 v2.2.0 h1:/KfZ310gOAFrXXol5VwnFEt+ucldD/0dsSRZwpHCP9w= +github.com/jsimonetti/rtnetlink/v2 v2.2.0/go.mod h1:lbjDHxC+5RJ08lzPeA90Ls2pEoId3F08MoEMlhfHxeI= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= From 051b08cc525b53f706326103d034c3c5e41416f8 Mon Sep 17 00:00:00 2001 From: Anthony Harivel Date: Mon, 9 Feb 2026 09:49:37 +0100 Subject: [PATCH 2/2] Add collector for SR-IOV network Virtual Function statistics Add a new netvf collector that exposes SR-IOV network VF statistics and configuration via rtnetlink. The collector queries netlink for interfaces with Virtual Functions and exposes per-VF metrics: - node_net_vf_info: VF configuration (MAC, VLAN, link state, spoof check, trust, PCI address) - node_net_vf_{receive,transmit}_{packets,bytes}_total: traffic counters - node_net_vf_{broadcast,multicast}_packets_total: packet type counters - node_net_vf_{receive,transmit}_dropped_total: drop counters All metrics include a pci_address label resolved from the sysfs virtfn symlink, enabling direct correlation with workloads that reference VFs by PCI BDF address (e.g. OpenStack Nova, libvirt, DPDK). The collector is disabled by default and can be enabled with --collector.netvf. PF device filtering is supported via --collector.netvf.device-include/exclude flags. Signed-off-by: Anthony Harivel --- README.md | 2 + collector/netvf_linux.go | 278 ++++++++++++++++++++++++++++++ collector/netvf_linux_test.go | 306 ++++++++++++++++++++++++++++++++++ 3 files changed, 586 insertions(+) create mode 100644 collector/netvf_linux.go create mode 100644 collector/netvf_linux_test.go diff --git a/README.md b/README.md index ec5adbf7ac..3ff6c56563 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ hwmon | chip | --collector.hwmon.chip-include | --collector.hwmon.chip-exclude hwmon | sensor | --collector.hwmon.sensor-include | --collector.hwmon.sensor-exclude interrupts | name | --collector.interrupts.name-include | --collector.interrupts.name-exclude netdev | device | --collector.netdev.device-include | --collector.netdev.device-exclude +netvf | device | --collector.netvf.device-include | --collector.netvf.device-exclude qdisk | device | --collector.qdisk.device-include | --collector.qdisk.device-exclude slabinfo | slab-names | --collector.slabinfo.slabs-include | --collector.slabinfo.slabs-exclude sysctl | all | --collector.sysctl.include | N/A @@ -202,6 +203,7 @@ logind | Exposes session counts from [logind](http://www.freedesktop.org/wiki/So meminfo\_numa | Exposes memory statistics from `/sys/devices/system/node/node[0-9]*/meminfo`, `/sys/devices/system/node/node[0-9]*/numastat`. | Linux mountstats | Exposes filesystem statistics from `/proc/self/mountstats`. Exposes detailed NFS client statistics. | Linux network_route | Exposes the routing table as metrics | Linux +netvf | Exposes SR-IOV Virtual Function statistics and configuration from netlink. | Linux pcidevice | Exposes pci devices' information including their link status and parent devices. | Linux perf | Exposes perf based metrics (Warning: Metrics are dependent on kernel configuration and settings). | Linux processes | Exposes aggregate process statistics from `/proc`. | Linux diff --git a/collector/netvf_linux.go b/collector/netvf_linux.go new file mode 100644 index 0000000000..a9b4e63835 --- /dev/null +++ b/collector/netvf_linux.go @@ -0,0 +1,278 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !nonetvf + +package collector + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/alecthomas/kingpin/v2" + "github.com/jsimonetti/rtnetlink/v2" + "github.com/prometheus/client_golang/prometheus" +) + +const netvfSubsystem = "net_vf" + +var ( + netvfDeviceInclude = kingpin.Flag("collector.netvf.device-include", "Regexp of PF devices to include (mutually exclusive to device-exclude).").String() + netvfDeviceExclude = kingpin.Flag("collector.netvf.device-exclude", "Regexp of PF devices to exclude (mutually exclusive to device-include).").String() +) + +func init() { + registerCollector("netvf", defaultDisabled, NewNetVFCollector) +} + +type netvfCollector struct { + logger *slog.Logger + deviceFilter deviceFilter + + info *prometheus.Desc + receivePackets *prometheus.Desc + transmitPackets *prometheus.Desc + receiveBytes *prometheus.Desc + transmitBytes *prometheus.Desc + broadcast *prometheus.Desc + multicast *prometheus.Desc + receiveDropped *prometheus.Desc + transmitDropped *prometheus.Desc +} + +func NewNetVFCollector(logger *slog.Logger) (Collector, error) { + if *netvfDeviceExclude != "" && *netvfDeviceInclude != "" { + return nil, errors.New("device-exclude & device-include are mutually exclusive") + } + + if *netvfDeviceExclude != "" { + logger.Info("Parsed flag --collector.netvf.device-exclude", "flag", *netvfDeviceExclude) + } + + if *netvfDeviceInclude != "" { + logger.Info("Parsed flag --collector.netvf.device-include", "flag", *netvfDeviceInclude) + } + + return &netvfCollector{ + logger: logger, + deviceFilter: newDeviceFilter(*netvfDeviceExclude, *netvfDeviceInclude), + info: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "info"), + "Virtual Function configuration information.", + []string{"device", "vf", "mac", "vlan", "link_state", "spoof_check", "trust", "pci_address"}, nil, + ), + receivePackets: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "receive_packets_total"), + "Number of received packets by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + transmitPackets: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "transmit_packets_total"), + "Number of transmitted packets by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + receiveBytes: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "receive_bytes_total"), + "Number of received bytes by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + transmitBytes: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "transmit_bytes_total"), + "Number of transmitted bytes by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + broadcast: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "broadcast_packets_total"), + "Number of broadcast packets received by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + multicast: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "multicast_packets_total"), + "Number of multicast packets received by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + receiveDropped: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "receive_dropped_total"), + "Number of dropped received packets by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + transmitDropped: prometheus.NewDesc( + prometheus.BuildFQName(namespace, netvfSubsystem, "transmit_dropped_total"), + "Number of dropped transmitted packets by the VF.", + []string{"device", "vf", "pci_address"}, nil, + ), + }, nil +} + +func (c *netvfCollector) Update(ch chan<- prometheus.Metric) error { + conn, err := rtnetlink.Dial(nil) + if err != nil { + return fmt.Errorf("failed to connect to rtnetlink: %w", err) + } + defer conn.Close() + + links, err := conn.Link.ListWithVFInfo() + if err != nil { + return fmt.Errorf("failed to list interfaces with VF info: %w", err) + } + + vfCount := 0 + for _, link := range links { + if link.Attributes == nil { + continue + } + + // Skip interfaces without VFs + if link.Attributes.NumVF == nil || *link.Attributes.NumVF == 0 { + continue + } + + device := link.Attributes.Name + + // Apply device filter + if c.deviceFilter.ignored(device) { + c.logger.Debug("Ignoring device", "device", device) + continue + } + + for _, vf := range link.Attributes.VFInfoList { + vfID := fmt.Sprintf("%d", vf.ID) + + // Emit info metric with VF configuration + mac := "" + if vf.MAC != nil { + mac = vf.MAC.String() + } + vlan := fmt.Sprintf("%d", vf.Vlan) + linkState := vfLinkStateString(vf.LinkState) + spoofCheck := fmt.Sprintf("%t", vf.SpoofCheck) + trust := fmt.Sprintf("%t", vf.Trust) + pciAddress := resolveVFPCIAddress(sysFilePath("class"), device, vf.ID) + + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, device, vfID, mac, vlan, linkState, spoofCheck, trust, pciAddress) + + // Emit stats metrics if available + if vf.Stats == nil { + c.logger.Debug("VF has no stats", "device", device, "vf", vf.ID) + vfCount++ + continue + } + + stats := vf.Stats + + ch <- prometheus.MustNewConstMetric(c.receivePackets, prometheus.CounterValue, float64(stats.RxPackets), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.transmitPackets, prometheus.CounterValue, float64(stats.TxPackets), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.receiveBytes, prometheus.CounterValue, float64(stats.RxBytes), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.transmitBytes, prometheus.CounterValue, float64(stats.TxBytes), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.broadcast, prometheus.CounterValue, float64(stats.Broadcast), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.multicast, prometheus.CounterValue, float64(stats.Multicast), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.receiveDropped, prometheus.CounterValue, float64(stats.RxDropped), device, vfID, pciAddress) + ch <- prometheus.MustNewConstMetric(c.transmitDropped, prometheus.CounterValue, float64(stats.TxDropped), device, vfID, pciAddress) + + vfCount++ + } + } + + if vfCount == 0 { + return ErrNoData + } + + return nil +} + +func vfLinkStateString(state rtnetlink.VFLinkState) string { + switch state { + case rtnetlink.VFLinkStateAuto: + return "auto" + case rtnetlink.VFLinkStateEnable: + return "enable" + case rtnetlink.VFLinkStateDisable: + return "disable" + default: + return "unknown" + } +} + +// resolveVFPCIAddress resolves the PCI BDF address of a VF by reading the +// sysfs virtfn symlink: /net//device/virtfn. +// Returns empty string if the symlink doesn't exist or can't be resolved. +func resolveVFPCIAddress(sysClassPath, pfDevice string, vfID uint32) string { + virtfnPath := filepath.Join(sysClassPath, "net", pfDevice, "device", fmt.Sprintf("virtfn%d", vfID)) + resolved, err := os.Readlink(virtfnPath) + if err != nil { + return "" + } + return filepath.Base(resolved) +} + +// vfMetrics holds parsed VF metrics for a single VF +type vfMetrics struct { + Device string + VFID uint32 + MAC string + Vlan uint32 + LinkState string + SpoofCheck bool + Trust bool + PCIAddress string + Stats *rtnetlink.VFStats +} + +// parseVFInfo extracts VF information from link messages for testing. +// sysClassPath is the path to the sysfs class directory used to resolve VF PCI addresses. +func parseVFInfo(links []rtnetlink.LinkMessage, filter *deviceFilter, logger *slog.Logger, sysClassPath string) []vfMetrics { + var result []vfMetrics + + for _, link := range links { + if link.Attributes == nil { + continue + } + + // Skip interfaces without VFs + if link.Attributes.NumVF == nil || *link.Attributes.NumVF == 0 { + continue + } + + device := link.Attributes.Name + + // Apply device filter + if filter.ignored(device) { + logger.Debug("Ignoring device", "device", device) + continue + } + + for _, vf := range link.Attributes.VFInfoList { + mac := "" + if vf.MAC != nil { + mac = vf.MAC.String() + } + + result = append(result, vfMetrics{ + Device: device, + VFID: vf.ID, + MAC: mac, + Vlan: vf.Vlan, + LinkState: vfLinkStateString(vf.LinkState), + SpoofCheck: vf.SpoofCheck, + Trust: vf.Trust, + PCIAddress: resolveVFPCIAddress(sysClassPath, device, vf.ID), + Stats: vf.Stats, + }) + } + } + + return result +} diff --git a/collector/netvf_linux_test.go b/collector/netvf_linux_test.go new file mode 100644 index 0000000000..d04e3d1b59 --- /dev/null +++ b/collector/netvf_linux_test.go @@ -0,0 +1,306 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !nonetvf + +package collector + +import ( + "io" + "log/slog" + "net" + "os" + "path/filepath" + "testing" + + "github.com/jsimonetti/rtnetlink/v2" +) + +func uint32Ptr(v uint32) *uint32 { + return &v +} + +var vfLinks = []rtnetlink.LinkMessage{ + { + // Interface without VFs + Attributes: &rtnetlink.LinkAttributes{ + Name: "eth0", + Stats64: &rtnetlink.LinkStats64{ + RXPackets: 1000, + TXPackets: 2000, + }, + }, + }, + { + // Interface with NumVF = 0 + Attributes: &rtnetlink.LinkAttributes{ + Name: "eth1", + NumVF: uint32Ptr(0), + }, + }, + { + // PF with 2 VFs + Attributes: &rtnetlink.LinkAttributes{ + Name: "enp3s0f0", + NumVF: uint32Ptr(2), + VFInfoList: []rtnetlink.VFInfo{ + { + ID: 0, + MAC: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x00}, + Vlan: 100, + LinkState: rtnetlink.VFLinkStateAuto, + SpoofCheck: true, + Trust: false, + Stats: &rtnetlink.VFStats{ + RxPackets: 1000, + TxPackets: 2000, + RxBytes: 100000, + TxBytes: 200000, + Broadcast: 10, + Multicast: 20, + RxDropped: 5, + TxDropped: 3, + }, + }, + { + ID: 1, + MAC: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x01}, + Vlan: 200, + LinkState: rtnetlink.VFLinkStateEnable, + SpoofCheck: false, + Trust: true, + Stats: &rtnetlink.VFStats{ + RxPackets: 3000, + TxPackets: 4000, + RxBytes: 300000, + TxBytes: 400000, + Broadcast: 30, + Multicast: 40, + RxDropped: 7, + TxDropped: 9, + }, + }, + }, + }, + }, + { + // Another PF with 1 VF (no stats) + Attributes: &rtnetlink.LinkAttributes{ + Name: "enp3s0f1", + NumVF: uint32Ptr(1), + VFInfoList: []rtnetlink.VFInfo{ + { + ID: 0, + MAC: net.HardwareAddr{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}, + Vlan: 0, + LinkState: rtnetlink.VFLinkStateDisable, + SpoofCheck: true, + Trust: false, + Stats: nil, // No stats available + }, + }, + }, + }, + { + // Nil attributes (should be skipped) + Attributes: nil, + }, +} + +func TestParseVFInfo(t *testing.T) { + filter := newDeviceFilter("", "") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + // Should have 3 VFs total (2 from enp3s0f0, 1 from enp3s0f1) + if want, got := 3, len(vfs); want != got { + t.Errorf("want %d VFs, got %d", want, got) + } +} + +func TestParseVFInfoDeviceFilter(t *testing.T) { + // Exclude enp3s0f1 + filter := newDeviceFilter("enp3s0f1", "") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + // Should have 2 VFs (only from enp3s0f0) + if want, got := 2, len(vfs); want != got { + t.Errorf("want %d VFs, got %d", want, got) + } + + for _, vf := range vfs { + if vf.Device == "enp3s0f1" { + t.Error("enp3s0f1 should be filtered out") + } + } +} + +func TestParseVFInfoDeviceInclude(t *testing.T) { + // Only include enp3s0f1 + filter := newDeviceFilter("", "^enp3s0f1$") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + // Should have 1 VF (only from enp3s0f1) + if want, got := 1, len(vfs); want != got { + t.Errorf("want %d VFs, got %d", want, got) + } + + if len(vfs) > 0 && vfs[0].Device != "enp3s0f1" { + t.Errorf("want device enp3s0f1, got %s", vfs[0].Device) + } +} + +func TestParseVFInfoStats(t *testing.T) { + filter := newDeviceFilter("", "^enp3s0f0$") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + if len(vfs) != 2 { + t.Fatalf("expected 2 VFs, got %d", len(vfs)) + } + + // Check VF 0 stats + vf0 := vfs[0] + if vf0.VFID != 0 { + t.Errorf("expected VF ID 0, got %d", vf0.VFID) + } + if vf0.Stats == nil { + t.Fatal("expected stats for VF 0") + } + if want, got := uint64(1000), vf0.Stats.RxPackets; want != got { + t.Errorf("want RxPackets %d, got %d", want, got) + } + if want, got := uint64(200000), vf0.Stats.TxBytes; want != got { + t.Errorf("want TxBytes %d, got %d", want, got) + } + + // Check VF 1 stats + vf1 := vfs[1] + if vf1.VFID != 1 { + t.Errorf("expected VF ID 1, got %d", vf1.VFID) + } + if want, got := uint64(4000), vf1.Stats.TxPackets; want != got { + t.Errorf("want TxPackets %d, got %d", want, got) + } +} + +func TestParseVFInfoMetadata(t *testing.T) { + filter := newDeviceFilter("", "^enp3s0f0$") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + if len(vfs) != 2 { + t.Fatalf("expected 2 VFs, got %d", len(vfs)) + } + + // Check VF 0 metadata + vf0 := vfs[0] + if want, got := "aa:bb:cc:dd:ee:00", vf0.MAC; want != got { + t.Errorf("want MAC %s, got %s", want, got) + } + if want, got := uint32(100), vf0.Vlan; want != got { + t.Errorf("want VLAN %d, got %d", want, got) + } + if want, got := "auto", vf0.LinkState; want != got { + t.Errorf("want LinkState %s, got %s", want, got) + } + if !vf0.SpoofCheck { + t.Error("expected SpoofCheck to be true") + } + if vf0.Trust { + t.Error("expected Trust to be false") + } + + // Check VF 1 metadata + vf1 := vfs[1] + if want, got := "enable", vf1.LinkState; want != got { + t.Errorf("want LinkState %s, got %s", want, got) + } + if vf1.SpoofCheck { + t.Error("expected SpoofCheck to be false") + } + if !vf1.Trust { + t.Error("expected Trust to be true") + } +} + +func TestParseVFInfoNoStats(t *testing.T) { + filter := newDeviceFilter("", "^enp3s0f1$") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + vfs := parseVFInfo(vfLinks, &filter, logger, "") + + if len(vfs) != 1 { + t.Fatalf("expected 1 VF, got %d", len(vfs)) + } + + vf := vfs[0] + if vf.Stats != nil { + t.Error("expected Stats to be nil for this VF") + } + if want, got := "disable", vf.LinkState; want != got { + t.Errorf("want LinkState %s, got %s", want, got) + } +} + +func TestVFLinkStateString(t *testing.T) { + tests := []struct { + state rtnetlink.VFLinkState + expected string + }{ + {rtnetlink.VFLinkStateAuto, "auto"}, + {rtnetlink.VFLinkStateEnable, "enable"}, + {rtnetlink.VFLinkStateDisable, "disable"}, + {rtnetlink.VFLinkState(99), "unknown"}, + } + + for _, tt := range tests { + got := vfLinkStateString(tt.state) + if got != tt.expected { + t.Errorf("vfLinkStateString(%d) = %s, want %s", tt.state, got, tt.expected) + } + } +} + +func TestResolveVFPCIAddress(t *testing.T) { + // Create a fake sysfs tree: /net//device/virtfn0 -> ../0000:65:01.0 + tmp := t.TempDir() + deviceDir := filepath.Join(tmp, "net", "enp3s0f0", "device") + if err := os.MkdirAll(deviceDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../0000:65:01.0", filepath.Join(deviceDir, "virtfn0")); err != nil { + t.Fatal(err) + } + + got := resolveVFPCIAddress(tmp, "enp3s0f0", 0) + if want := "0000:65:01.0"; got != want { + t.Errorf("resolveVFPCIAddress() = %q, want %q", got, want) + } +} + +func TestResolveVFPCIAddressMissing(t *testing.T) { + tmp := t.TempDir() + + got := resolveVFPCIAddress(tmp, "enp3s0f0", 0) + if got != "" { + t.Errorf("resolveVFPCIAddress() = %q, want empty string for missing symlink", got) + } +}