From 4082472f7dc045768640527a90340a8d4353044c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 14 Sep 2026 18:48:31 +0200 Subject: [PATCH 1/4] yanger: add higher-layer-if and lower-layer-if to interfaces The ietf-interfaces leaf-lists higher-layer-if and lower-layer-if were never populated, so operational data gave no general view of how interfaces stack. Only the VLAN augment carried its lower-layer-if. The kernel already keeps this adjacency in sysfs, as upper_ and lower_ links in each interface directory. Read them per interface and drop references to interfaces hidden from operational (group internal, CAN) so no leafref points at a missing entry. Fixes part of issue #514 Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 3 + src/statd/python/cli_pretty/cli_pretty.py | 10 +++- src/statd/python/yanger/host.py | 33 +++++++++++ .../python/yanger/ietf_interfaces/link.py | 37 ++++++++++-- .../verify_all_interface_types/test.adoc | 2 + .../verify_all_interface_types/test.py | 56 +++++++++++++++++++ .../cli/show-interfaces_-n_br-D | 1 + .../cli/show-interfaces_-n_br-Q | 3 + .../cli/show-interfaces_-n_br-X | 1 + .../cli/show-interfaces_-n_veth0a | 1 + .../cli/show-interfaces_-n_veth0a.20 | 1 + .../cli/show-interfaces_-n_veth0b | 1 + .../statd/interfaces-all/ietf-interfaces.json | 44 +++++++++++++++ .../statd/interfaces-all/operational.json | 44 +++++++++++++++ .../rootfs/sys/class/net/br-D/lower_veth0a.20 | 0 .../rootfs/sys/class/net/br-Q.40/lower_br-Q | 0 .../system/rootfs/sys/class/net/br-Q/lower_e3 | 0 .../rootfs/sys/class/net/br-Q/lower_veth0b | 0 .../rootfs/sys/class/net/br-Q/upper_br-Q.40 | 0 .../rootfs/sys/class/net/br-X/lower_e2.30 | 0 .../rootfs/sys/class/net/e2.30/lower_e2 | 0 .../rootfs/sys/class/net/e2.30/upper_br-X | 0 .../rootfs/sys/class/net/e2/upper_e2.30 | 0 .../rootfs/sys/class/net/e3.10/lower_e3 | 0 .../system/rootfs/sys/class/net/e3/upper_br-Q | 0 .../rootfs/sys/class/net/e3/upper_e3.10 | 0 .../sys/class/net/veth0a.20/lower_veth0a | 0 .../rootfs/sys/class/net/veth0a.20/upper_br-D | 0 .../sys/class/net/veth0a/upper_veth0a.20 | 0 .../rootfs/sys/class/net/veth0b/upper_br-Q | 0 30 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-D/lower_veth0a.20 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q.40/lower_br-Q create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_e3 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_veth0b create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/upper_br-Q.40 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-X/lower_e2.30 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/lower_e2 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/upper_br-X create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2/upper_e2.30 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3.10/lower_e3 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_br-Q create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_e3.10 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/lower_veth0a create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/upper_br-D create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a/upper_veth0a.20 create mode 100644 test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0b/upper_br-Q diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d6d9fa15a8..2426966810 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -43,6 +43,9 @@ All notable changes to the project are documented in this file. editor, show mesh peers on the WiFi and interface status pages, and add an editor section for access point roaming (802.11k/r/v, band steering, OKC). +- Add `higher-layer-if` and `lower-layer-if` to interface operational + status, listing the interfaces stacked directly on top of or beneath + each one, e.g., a VLAN interface and its parent, issue #514 ### Fixes diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index c6b254b950..4132708b05 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1044,6 +1044,8 @@ def __init__(self, data): self.type = data.get('type', '') self.index = data.get('if-index', '') self.oper_status = data.get('oper-status', '') + self.higher_layer = data.get('higher-layer-if', []) + self.lower_layer = data.get('lower-layer-if', []) self.autoneg = get_json_data('unknown', self.data, 'ieee802-ethernet-interface:ethernet', 'auto-negotiation', 'enable') self.duplex = get_json_data('', self.data,'ieee802-ethernet-interface:ethernet','duplex') @@ -1670,8 +1672,12 @@ def pr_iface(self): forwarding = "enabled" if self.name in Iface._routing_ifaces else "disabled" print(f"{'ip forwarding':<{19}}: {forwarding}") - if self.lower_if: - print(f"{'lower-layer-if':<{19}}: {self.lower_if}") + # Older operational data only has the VLAN augment's lower-layer-if + lower = self.lower_layer or ([self.lower_if] if self.lower_if else []) + if lower: + self._pr_label_list('lower-layer-if', lower) + if self.higher_layer: + self._pr_label_list('higher-layer-if', self.higher_layer) if label := self._phy_label(): print(f"{'link mode':<{19}}: {label}") diff --git a/src/statd/python/yanger/host.py b/src/statd/python/yanger/host.py index 8fd4f01cba..ccde7c7842 100644 --- a/src/statd/python/yanger/host.py +++ b/src/statd/python/yanger/host.py @@ -59,6 +59,15 @@ def read(self, path): """ pass + @abc.abstractmethod + def listdir(self, path): + """Get the entries of directory path + + Returns an empty list if the directory is not readable. + + """ + pass + def read_multiline(self, path, default=None): """Get lines of content from path @@ -122,6 +131,12 @@ def exists(self, path: str) -> bool: except OSError: return False + def listdir(self, path): + try: + return os.listdir(path) + except OSError: + return [] + class Remotehost(Localhost): def __init__(self, prefix, capdir): super().__init__() @@ -188,6 +203,17 @@ def read(self, path): return out + def listdir(self, path): + entries = self._run(("ls", path), default="", log=False).split() + + if self.capdir: + dirname = os.path.join(self.capdir, "rootfs", path[1:]) + os.makedirs(dirname, exist_ok=True) + for entry in entries: + open(os.path.join(dirname, entry), "a", encoding='utf-8').close() + + return entries + class Replayhost(Host): def SlugOf(cmd): @@ -234,3 +260,10 @@ def read(self, path): except: common.LOG.error(f"No recording found for file \"{path}\"") raise + + def listdir(self, path): + path = os.path.join(self.replaydir, "rootfs", path[1:]) + try: + return os.listdir(path) + except OSError: + return [] diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index f4ae79a2bb..f5ef6a7cad 100644 --- a/src/statd/python/yanger/ietf_interfaces/link.py +++ b/src/statd/python/yanger/ietf_interfaces/link.py @@ -26,6 +26,31 @@ def statistics(iplink): return statistics +def hidden(iplink): + """Interfaces never reported in operational: internal plumbing and CAN""" + if iplink.get("group") == "internal": + return True + + return iplink.get("link_type") in ("can", "vcan") + + +def layers(ifname): + """Directly adjacent interfaces, from the kernel's sysfs upper_/lower_ links""" + entries = HOST.listdir(f"/sys/class/net/{ifname}") + higher = [e[len("upper_"):] for e in entries if e.startswith("upper_")] + lower = [e[len("lower_"):] for e in entries if e.startswith("lower_")] + if not higher and not lower: + return [], [] + + # Neighbors not shown in operational must not be referenced either + links = common.iplinks() + + def visible(name): + return name in links and not hidden(links[name]) + + return sorted(filter(visible, higher)), sorted(filter(visible, lower)) + + def iplink2yang_type(iplink): ifname=iplink["ifname"] @@ -149,6 +174,12 @@ def interface(iplink, ipaddr, systemjson=None): if ptpcap := ptp_capabilities(iplink["ifname"], systemjson): interface["infix-interfaces:ptp-capabilities"] = ptpcap + higher, lower = layers(iplink["ifname"]) + if higher: + interface["higher-layer-if"] = higher + if lower: + interface["lower-layer-if"] = lower + match interface["type"]: case "infix-if-type:bridge": if br := bridge.bridge(iplink): @@ -203,11 +234,7 @@ def interfaces(ifname=None): interfaces = [] for ifname, iplink in links.items(): - if iplink.get("group") == "internal": - continue - - link_type = iplink.get("link_type") - if link_type in ("can", "vcan"): + if hidden(iplink): continue ipaddr = addrs.get(ifname, {}) diff --git a/test/case/interfaces/verify_all_interface_types/test.adoc b/test/case/interfaces/verify_all_interface_types/test.adoc index e82bbbaf28..7165935802 100644 --- a/test/case/interfaces/verify_all_interface_types/test.adoc +++ b/test/case/interfaces/verify_all_interface_types/test.adoc @@ -39,5 +39,7 @@ image::topology.svg[Verify that All Interface Types Can Be Created topology, ali . Verify interfaces 'veth0a.20', 'ethQ.10', 'ethX.30', 'ethQ.10' and 'br-Q.40' are of type 'vlan' . Verify GRE interfaces 'gre-v4', 'gre-v6', 'gretap-v4' and 'gretap-v6' . Verify VxLAN interfaces 'vxlan-v4' and 'vxlan-v6' +. Verify higher-layer-if and lower-layer-if of bridge stacks +. Verify standalone interfaces have no higher-layer-if or lower-layer-if diff --git a/test/case/interfaces/verify_all_interface_types/test.py b/test/case/interfaces/verify_all_interface_types/test.py index 46346d9803..474904108b 100755 --- a/test/case/interfaces/verify_all_interface_types/test.py +++ b/test/case/interfaces/verify_all_interface_types/test.py @@ -19,6 +19,36 @@ import infamy import infamy.iface as iface +from infamy.util import until + + +def layers(target): + """Map each interface to its sorted (higher-layer-if, lower-layer-if)""" + data = target.get_data("/ietf-interfaces:interfaces") + return { + entry["name"]: (sorted(entry.get("higher-layer-if", [])), + sorted(entry.get("lower-layer-if", []))) + for entry in data["interfaces"]["interface"] + } + + +def verify_layers(target, expected): + """Verify (higher-layer-if, lower-layer-if) per interface in expected""" + expected = {name: (sorted(higher), sorted(lower)) + for name, (higher, lower) in expected.items()} + + actual = {} + + def matches(): + actual.update(layers(target)) + return all(actual.get(name) == want for name, want in expected.items()) + + try: + until(matches) + except Exception as err: + diff = {name: (actual.get(name), want) + for name, want in expected.items() if actual.get(name) != want} + raise AssertionError(f"layer mismatch, (got, expected): {diff}") from err def verify_interface(target, interface, expected_type): @@ -341,4 +371,30 @@ def verify_interface(target, interface, expected_type): with test.step("Verify VxLAN interfaces 'vxlan-v4' and 'vxlan-v6'"): verify_interface(target, "vxlan-v4", "vxlan") verify_interface(target, "vxlan-v6", "vxlan") + + with test.step("Verify higher-layer-if and lower-layer-if of bridge stacks"): + verify_layers(target, { + eth_X: ([eth_X_30], []), + eth_X_30: ([br_X], [eth_X]), + br_X: ([], [eth_X_30]), + + veth_a: ([veth_a_20], []), + veth_a_20: ([br_D], [veth_a]), + br_D: ([], [veth_a_20]), + + eth_Q: ([br_Q, eth_Q_10], []), + veth_b: ([br_Q], []), + eth_Q_10: ([], [eth_Q]), + br_Q: ([br_Q_40], [eth_Q, veth_b]), + br_Q_40: ([], [br_Q]), + }) + + with test.step("Verify standalone interfaces have no higher-layer-if or lower-layer-if"): + verify_layers(target, { + loopback: ([], []), + br_0: ([], []), + "gre-v4": ([], []), + "vxlan-v4": ([], []), + }) + test.succeed() diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D index 2c30ec700a..fde7c73d67 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D @@ -4,6 +4,7 @@ index : 15 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : veth0a.20 physical address : 00:a0:85:00:03:00 ipv4 addresses : 10.0.0.1/8 (static) 192.168.20.1/24 (static) diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q index f5be2191d0..41758d37bb 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q @@ -4,6 +4,9 @@ index : 16 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : e3 + veth0b +higher-layer-if : br-Q.40 physical address : 00:a0:85:00:03:00 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X index 5e9bc61267..924b493e7d 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X @@ -4,6 +4,7 @@ index : 11 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : e2.30 physical address : 00:a0:85:00:03:00 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a index 5d7db92eac..82be3ec46c 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a @@ -4,6 +4,7 @@ index : 13 mtu : 1500 operational status : up ip forwarding : disabled +higher-layer-if : veth0a.20 physical address : 6e:d0:98:c4:e7:ef ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 index 4c3c26e32f..2918900cfd 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 @@ -5,6 +5,7 @@ mtu : 1500 operational status : up ip forwarding : disabled lower-layer-if : veth0a +higher-layer-if : br-D physical address : 6e:d0:98:c4:e7:ef ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b index 5b286a6676..f26022a1ae 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b @@ -4,6 +4,7 @@ index : 12 mtu : 1500 operational status : up ip forwarding : disabled +higher-layer-if : br-Q physical address : 36:da:80:06:7f:99 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/ietf-interfaces.json b/test/case/statd/interfaces-all/ietf-interfaces.json index e476101bbe..661b8f2a22 100644 --- a/test/case/statd/interfaces-all/ietf-interfaces.json +++ b/test/case/statd/interfaces-all/ietf-interfaces.json @@ -86,6 +86,9 @@ } ] }, + "higher-layer-if": [ + "e2.30" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -108,6 +111,10 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q", + "e3.10" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -287,6 +294,12 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-X" + ], + "lower-layer-if": [ + "e2" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 30, @@ -323,6 +336,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "e2.30" + ], "infix-interfaces:bridge": { "multicast": { "snooping": false, @@ -350,6 +366,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q" + ], "infix-interfaces:veth": { "peer": "veth0a" }, @@ -388,6 +407,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "veth0a.20" + ], "infix-interfaces:veth": { "peer": "veth0b" } @@ -408,6 +430,12 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-D" + ], + "lower-layer-if": [ + "veth0a" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 20, @@ -471,6 +499,9 @@ } ] }, + "lower-layer-if": [ + "veth0a.20" + ], "infix-interfaces:bridge": { "multicast": { "snooping": false, @@ -497,6 +528,13 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q.40" + ], + "lower-layer-if": [ + "e3", + "veth0b" + ], "infix-interfaces:bridge": { "vlans": { "proto": "ieee802-dot1q-types:c-vlan", @@ -568,6 +606,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "br-Q" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 40, @@ -587,6 +628,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "e3" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 10, diff --git a/test/case/statd/interfaces-all/operational.json b/test/case/statd/interfaces-all/operational.json index cfc2b6ed80..f729a957d4 100644 --- a/test/case/statd/interfaces-all/operational.json +++ b/test/case/statd/interfaces-all/operational.json @@ -64,6 +64,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "e2.30" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -94,6 +97,10 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q", + "e3.10" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -276,6 +283,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-X" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -305,6 +315,9 @@ "lower-layer-if": "e2", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "e2" + ], "name": "e2.30", "oper-status": "up", "phys-address": "00:a0:85:00:03:02", @@ -328,6 +341,9 @@ "multicast-filter": [] } }, + "lower-layer-if": [ + "e2.30" + ], "name": "br-X", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -335,6 +351,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -373,6 +392,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "veth0a.20" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -394,6 +416,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-D" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -423,6 +448,9 @@ "lower-layer-if": "veth0a", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "veth0a" + ], "name": "veth0a.20", "oper-status": "up", "phys-address": "6e:d0:98:c4:e7:ef", @@ -473,6 +501,9 @@ "multicast-filter": [] } }, + "lower-layer-if": [ + "veth0a.20" + ], "name": "br-D", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -483,6 +514,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q.40" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -547,6 +581,10 @@ ] } }, + "lower-layer-if": [ + "e3", + "veth0b" + ], "name": "br-Q", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -569,6 +607,9 @@ "lower-layer-if": "br-Q", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "br-Q" + ], "name": "br-Q.40", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -588,6 +629,9 @@ "lower-layer-if": "e3", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "e3" + ], "name": "e3.10", "oper-status": "up", "phys-address": "00:a0:85:00:03:03", diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-D/lower_veth0a.20 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-D/lower_veth0a.20 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q.40/lower_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q.40/lower_br-Q new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_e3 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_e3 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_veth0b b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_veth0b new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/upper_br-Q.40 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/upper_br-Q.40 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-X/lower_e2.30 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-X/lower_e2.30 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/lower_e2 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/lower_e2 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/upper_br-X b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/upper_br-X new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2/upper_e2.30 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2/upper_e2.30 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3.10/lower_e3 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3.10/lower_e3 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_br-Q new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_e3.10 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_e3.10 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/lower_veth0a b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/lower_veth0a new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/upper_br-D b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/upper_br-D new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a/upper_veth0a.20 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a/upper_veth0a.20 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0b/upper_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0b/upper_br-Q new file mode 100644 index 0000000000..e69de29bb2 From 0aee5552948197c800d2c6f7c55e9aeabc92bdbf Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 14 Sep 2026 18:48:32 +0200 Subject: [PATCH 2/4] statd: share the date-and-time formatter The mDNS neighbor code has a private formatter for YANG date-and-time strings. More operational leaves need the same encoding, so give it a home in shared.c and let callers pass the time to format. Signed-off-by: Joachim Wiberg --- src/statd/avahi.c | 12 ++---------- src/statd/shared.c | 10 ++++++++++ src/statd/shared.h | 3 +++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/statd/avahi.c b/src/statd/avahi.c index 1063dcee83..8eb1655061 100644 --- a/src/statd/avahi.c +++ b/src/statd/avahi.c @@ -36,6 +36,7 @@ #include #include "avahi.h" +#include "shared.h" /* Complete the opaque avahi types declared in avahi-common/watch.h */ struct AvahiWatch { @@ -336,15 +337,6 @@ static void free_all(struct mdns_ctx *ctx) #define XPATH_BASE "/infix-services:mdns/neighbors" -static void format_timestamp(char *buf, size_t sz) -{ - struct tm tm; - time_t now = time(NULL); - - gmtime_r(&now, &tm); - strftime(buf, sz, "%Y-%m-%dT%H:%M:%S+00:00", &tm); -} - static int sr_setstr(sr_session_ctx_t *ses, const char *xpath, const char *val) { int err = sr_set_item_str(ses, xpath, val, NULL, 0); @@ -449,7 +441,7 @@ static void ds_push_resolver(struct mdns_ctx *ctx, struct avahi_service *svc, } /* last-seen */ - format_timestamp(ts, sizeof(ts)); + format_timestamp(time(NULL), ts, sizeof(ts)); snprintf(xpath, sizeof(xpath), XPATH_BASE "/neighbor[hostname='%s']/last-seen", svc->hostname); err = err ?: sr_setstr(ctx->sr_ses, xpath, ts); diff --git a/src/statd/shared.c b/src/statd/shared.c index 762fbc74fe..50a6b69512 100644 --- a/src/statd/shared.c +++ b/src/statd/shared.c @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -76,3 +77,12 @@ int ip_link_check_group(const char *ifname, const char *group) return 0; } + +/* YANG date-and-time, always in UTC */ +void format_timestamp(time_t when, char *buf, size_t sz) +{ + struct tm tm; + + gmtime_r(&when, &tm); + strftime(buf, sz, "%Y-%m-%dT%H:%M:%S+00:00", &tm); +} diff --git a/src/statd/shared.h b/src/statd/shared.h index 7689ed7354..7225c48090 100644 --- a/src/statd/shared.h +++ b/src/statd/shared.h @@ -3,9 +3,12 @@ #ifndef STATD_SHARED_H_ #define STATD_SHARED_H_ +#include +#include #include json_t *json_get_output(const char *cmd); int ip_link_check_group(const char *ifname, const char *group); +void format_timestamp(time_t when, char *buf, size_t sz); #endif From 860d12f47b14c6e80b99a4b0fb6bd6d3da0bc20c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 14 Sep 2026 18:48:33 +0200 Subject: [PATCH 3/4] statd: add last-change to interface operational status The ietf-interfaces leaf last-change, the time an interface entered its current operational state, was missing. Nothing on the system keeps it: the kernel has no ifLastChange and statd only reads state on demand. Track it in statd. A netlink socket in the RTMGRP_LINK group feeds an in-memory map keyed by interface name, stamping every operational state change with the monotonic clock. The interfaces callback adds the leaf to yanger's output for each interface with a recorded change, converting to wall-clock time at that point so a later NTP step does not skew old stamps. Interfaces whose state predates statd get no last-change, as RFC 8343 requires. Fixes #514 Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 + src/statd/Makefile.am | 2 +- src/statd/iface.c | 284 ++++++++++++++++++ src/statd/iface.h | 40 +++ src/statd/python/cli_pretty/cli_pretty.py | 3 + src/statd/statd.c | 13 +- .../interfaces/iface_enable_disable/test.adoc | 4 +- .../interfaces/iface_enable_disable/test.py | 23 +- test/infamy/iface.py | 7 + 9 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 src/statd/iface.c create mode 100644 src/statd/iface.h diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 2426966810..a3572cce5e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -46,6 +46,8 @@ All notable changes to the project are documented in this file. - Add `higher-layer-if` and `lower-layer-if` to interface operational status, listing the interfaces stacked directly on top of or beneath each one, e.g., a VLAN interface and its parent, issue #514 +- Add `last-change` to interface operational status: the time the + interface entered its current operational state, issue #514 ### Fixes diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am index 6b44885224..3c5fddbfb7 100644 --- a/src/statd/Makefile.am +++ b/src/statd/Makefile.am @@ -2,7 +2,7 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 sbin_PROGRAMS = statd -statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h avahi.c avahi.h +statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h avahi.c avahi.h iface.c iface.h statd_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE statd_CPPFLAGS += -DSTATD_VERSION=\"$(PACKAGE_VERSION)\" statd_CFLAGS = -W -Wall -Wextra diff --git a/src/statd/iface.c b/src/statd/iface.c new file mode 100644 index 0000000000..8c5e55d455 --- /dev/null +++ b/src/statd/iface.c @@ -0,0 +1,284 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +/* + * Track interface state changes over rtnetlink to provide the + * ietf-interfaces last-change leaf, see issue #514. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "iface.h" +#include "shared.h" + +static time_t monotonic(void) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec; +} + +static struct iface *iface_find(struct iface_ctx *ctx, const char *name) +{ + struct iface *l; + + TAILQ_FOREACH(l, &ctx->ifaces, entries) { + if (!strcmp(l->name, name)) + return l; + } + + return NULL; +} + +static struct iface *iface_find_index(struct iface_ctx *ctx, int ifindex) +{ + struct iface *l; + + TAILQ_FOREACH(l, &ctx->ifaces, entries) { + if (l->ifindex == ifindex) + return l; + } + + return NULL; +} + +static void iface_del(struct iface_ctx *ctx, struct iface *l) +{ + TAILQ_REMOVE(&ctx->ifaces, l, entries); + free(l); +} + +static void iface_update(struct iface_ctx *ctx, int ifindex, const char *name, + uint8_t operstate, int dump) +{ + struct iface *l; + + l = iface_find(ctx, name); + if (!l) { + l = iface_find_index(ctx, ifindex); + if (l) { + DEBUG("Link %s renamed %s", l->name, name); + snprintf(l->name, sizeof(l->name), "%s", name); + } + } + + if (!l) { + l = calloc(1, sizeof(*l)); + if (!l) { + ERRNO("Failed allocating link %s", name); + return; + } + + snprintf(l->name, sizeof(l->name), "%s", name); + l->operstate = operstate; + if (!dump) + l->changed = monotonic(); + TAILQ_INSERT_TAIL(&ctx->ifaces, l, entries); + DEBUG("Link %s added, operstate %u", name, operstate); + } else if (l->operstate != operstate) { + DEBUG("Link %s operstate %u -> %u", name, l->operstate, operstate); + l->operstate = operstate; + l->changed = monotonic(); + } + + l->ifindex = ifindex; +} + +static void iface_parse(struct iface_ctx *ctx, struct nlmsghdr *nlh, int dump) +{ + struct ifinfomsg *ifi = NLMSG_DATA(nlh); + int len = nlh->nlmsg_len - NLMSG_LENGTH(sizeof(*ifi)); + uint8_t operstate = IF_OPER_UNKNOWN; + const char *name = NULL; + struct rtattr *rta; + struct iface *l; + + for (rta = IFLA_RTA(ifi); RTA_OK(rta, len); rta = RTA_NEXT(rta, len)) { + switch (rta->rta_type & NLA_TYPE_MASK) { + case IFLA_IFNAME: + name = RTA_DATA(rta); + break; + case IFLA_OPERSTATE: + operstate = *(uint8_t *)RTA_DATA(rta); + break; + } + } + + if (!name) + return; + + if (nlh->nlmsg_type == RTM_DELLINK) { + l = iface_find(ctx, name); + if (l) { + DEBUG("Link %s removed", name); + iface_del(ctx, l); + } + return; + } + + iface_update(ctx, ifi->ifi_index, name, operstate, dump); +} + +static void iface_purge(struct iface_ctx *ctx) +{ + struct iface *l; + + while ((l = TAILQ_FIRST(&ctx->ifaces))) + iface_del(ctx, l); +} + +/* Request a full link dump, replies are told apart from events by sequence */ +static int iface_dump(struct iface_ctx *ctx) +{ + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req = { + .nlh = { + .nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)), + .nlmsg_type = RTM_GETLINK, + .nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP, + }, + .ifi = { + .ifi_family = AF_UNSPEC, + }, + }; + + if (!++ctx->seq) + ctx->seq = 1; + req.nlh.nlmsg_seq = ctx->seq; + + if (send(ctx->sd, &req, req.nlh.nlmsg_len, 0) < 0) { + ERRNO("Failed requesting link dump"); + ctx->seq = 0; + return -1; + } + + return 0; +} + +static void iface_io_cb(struct ev_loop *, ev_io *w, int) +{ + struct iface_ctx *ctx = (struct iface_ctx *)w; + static char buf[32768]; + struct nlmsghdr *nlh; + int len; + + for (;;) { + len = recv(ctx->sd, buf, sizeof(buf), MSG_DONTWAIT); + if (len < 0) { + if (errno == EINTR) + continue; + if (errno == EAGAIN) + break; + if (errno == ENOBUFS) { + WARN("Link event overrun, resyncing"); + iface_purge(ctx); + iface_dump(ctx); + continue; + } + + ERRNO("Failed reading link events"); + break; + } + + for (nlh = (struct nlmsghdr *)buf; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) { + int dump = ctx->seq && nlh->nlmsg_seq == ctx->seq; + + switch (nlh->nlmsg_type) { + case RTM_NEWLINK: + case RTM_DELLINK: + iface_parse(ctx, nlh, dump); + break; + case NLMSG_DONE: + if (dump) + ctx->seq = 0; + break; + case NLMSG_ERROR: + if (dump) { + struct nlmsgerr *e = NLMSG_DATA(nlh); + + errno = -e->error; + ERRNO("Link dump failed"); + ctx->seq = 0; + } + break; + default: + break; + } + } + } +} + +int iface_ctx_init(struct iface_ctx *ctx, struct ev_loop *loop) +{ + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + .nl_groups = RTMGRP_LINK, + }; + + TAILQ_INIT(&ctx->ifaces); + ctx->loop = loop; + + ctx->sd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_ROUTE); + if (ctx->sd < 0) { + ERRNO("Failed opening link event socket"); + return -1; + } + + if (bind(ctx->sd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + ERRNO("Failed subscribing to link events"); + close(ctx->sd); + ctx->sd = -1; + return -1; + } + + ev_io_init(&ctx->io, iface_io_cb, ctx->sd, EV_READ); + ev_io_start(loop, &ctx->io); + + return iface_dump(ctx); +} + +void iface_ctx_exit(struct iface_ctx *ctx) +{ + if (ctx->sd >= 0) { + ev_io_stop(ctx->loop, &ctx->io); + close(ctx->sd); + ctx->sd = -1; + } + + iface_purge(ctx); +} + +/* Add last-change to every interface in tree whose state changed since start */ +void iface_annotate(struct iface_ctx *ctx, struct lyd_node *tree) +{ + time_t now = time(NULL), mono = monotonic(); + struct lyd_node *iface; + + if (!tree) + return; + + LY_LIST_FOR(lyd_child(tree), iface) { + struct iface *l; + char buf[32]; + + l = iface_find(ctx, lydx_get_cattr(iface, "name") ?: ""); + if (!l || !l->changed) + continue; + + format_timestamp(now - (mono - l->changed), buf, sizeof(buf)); + if (lyd_new_term(iface, NULL, "last-change", buf, 0, NULL)) + WARN("Failed adding last-change to interface %s", l->name); + } +} diff --git a/src/statd/iface.h b/src/statd/iface.h new file mode 100644 index 0000000000..a7d456aaf3 --- /dev/null +++ b/src/statd/iface.h @@ -0,0 +1,40 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#ifndef STATD_IFACE_H_ +#define STATD_IFACE_H_ + +#include +#include +#include +#include + +#include +#include + +/* + * In-memory link state, keyed by interface name, tracked over rtnetlink. + * Renames are recognized by ifindex, which the kernel keeps across them. + */ + +struct iface { + char name[IFNAMSIZ]; + int ifindex; + uint8_t operstate; /* IF_OPER_* */ + time_t changed; /* CLOCK_MONOTONIC, 0: state predates statd */ + TAILQ_ENTRY(iface) entries; +}; + +struct iface_ctx { + ev_io io; /* MUST be first (cast from ev_io *) */ + struct ev_loop *loop; + int sd; + uint32_t seq; /* sequence of ongoing dump, 0: none */ + TAILQ_HEAD(, iface) ifaces; +}; + +int iface_ctx_init(struct iface_ctx *ctx, struct ev_loop *loop); +void iface_ctx_exit(struct iface_ctx *ctx); + +void iface_annotate(struct iface_ctx *ctx, struct lyd_node *tree); + +#endif diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 4132708b05..2ed4a79468 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1044,6 +1044,7 @@ def __init__(self, data): self.type = data.get('type', '') self.index = data.get('if-index', '') self.oper_status = data.get('oper-status', '') + self.last_change = data.get('last-change', '') self.higher_layer = data.get('higher-layer-if', []) self.lower_layer = data.get('lower-layer-if', []) self.autoneg = get_json_data('unknown', self.data, 'ieee802-ethernet-interface:ethernet', @@ -1668,6 +1669,8 @@ def pr_iface(self): print(f"{'mtu':<{19}}: {self.mtu}") if self.oper(): print(f"{'operational status':<{19}}: {self.oper(detail=True)}") + if self.last_change: + print(f"{'last change':<{19}}: {Date.from_yang(self.last_change).pretty()}") forwarding = "enabled" if self.name in Iface._routing_ifaces else "disabled" print(f"{'ip forwarding':<{19}}: {forwarding}") diff --git a/src/statd/statd.c b/src/statd/statd.c index e338725f01..6509f601c6 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -31,6 +31,7 @@ #include "shared.h" #include "journal.h" +#include "iface.h" #include "avahi.h" /* New kernel feature, not in sys/mman.h yet */ @@ -72,6 +73,7 @@ struct statd { struct ev_loop *ev_loop; struct journal_ctx journal; /* Periodic operational snapshots */ struct mdns_ctx mdns; /* mDNS neighbor monitor */ + struct iface_ctx iface; /* Interface state change tracking */ }; static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent, @@ -157,7 +159,7 @@ static char *xpath_extract(const char *xpath, const char *key) static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, const char *, const char *xpath, uint32_t, - struct lyd_node **parent, __attribute__((unused)) void *priv) + struct lyd_node **parent, void *priv) { char *yanger_args[5] = { YANGER_BINPATH, @@ -166,6 +168,7 @@ static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, NULL, NULL }; + struct statd *statd = priv; char *ifname = NULL; const struct ly_ctx *ctx; sr_conn_ctx_t *con; @@ -193,6 +196,8 @@ static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, err = ly_add_yanger_data(ctx, parent, yanger_args); if (err) ERROR("Failed adding yanger data for %s", ifname ?: model); + else + iface_annotate(&statd->iface, *parent); free(ifname); sr_release_context(con); @@ -382,7 +387,7 @@ static int subscribe(struct statd *statd, char *model, char *xpath, memset(sub, 0, sizeof(struct sub)); DEBUG("Subscribe to events for \"%s\"", xpath); - err = sr_oper_get_subscribe(statd->sr_ses, model, xpath, cb, sub, + err = sr_oper_get_subscribe(statd->sr_ses, model, xpath, cb, statd, SR_SUBSCR_DEFAULT | SR_SUBSCR_NO_THREAD | SR_SUBSCR_DONE_ONLY, &sub->sr_sub); if (err) { @@ -596,6 +601,9 @@ int main(int argc, char *argv[]) if (mdns_ctx_init(&statd.mdns, statd.ev_loop, statd.sr_conn)) INFO("mDNS neighbor monitoring not available"); + if (iface_ctx_init(&statd.iface, statd.ev_loop)) + WARN("Interface state change tracking not available"); + /* Signal readiness to Finit */ pidfile(NULL); @@ -605,6 +613,7 @@ int main(int argc, char *argv[]) /* We should never get here during normal operation */ INFO("Status daemon shutting down"); + iface_ctx_exit(&statd.iface); mdns_ctx_exit(&statd.mdns); journal_stop(&statd.journal); diff --git a/test/case/interfaces/iface_enable_disable/test.adoc b/test/case/interfaces/iface_enable_disable/test.adoc index 05ed3f838b..f0a3039d70 100644 --- a/test/case/interfaces/iface_enable_disable/test.adoc +++ b/test/case/interfaces/iface_enable_disable/test.adoc @@ -7,7 +7,8 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/iface_enable_disabl Verify interface status properly propagate changes when an interface is disabled and then re-enabled. -Both admin-status and oper-status are verified. +Both admin-status and oper-status are verified, as well as last-change, +which must advance with each transition of the operational state. ==== Topology @@ -21,6 +22,7 @@ image::topology.svg[Interface Status topology, align=center, scaledwidth=75%] . Verify the interface is disabled . Enable the interface and assign an IP address . Verify the interface is enabled +. Verify last-change reflects the transition to up . Verify it is possible to ping the interface diff --git a/test/case/interfaces/iface_enable_disable/test.py b/test/case/interfaces/iface_enable_disable/test.py index def6c8728a..239173396b 100755 --- a/test/case/interfaces/iface_enable_disable/test.py +++ b/test/case/interfaces/iface_enable_disable/test.py @@ -5,14 +5,23 @@ Verify interface status properly propagate changes when an interface is disabled and then re-enabled. -Both admin-status and oper-status are verified. +Both admin-status and oper-status are verified, as well as last-change, +which must advance with each transition of the operational state. """ +from datetime import datetime + import infamy from infamy.util import parallel, until import infamy.iface as iface +def dut_now(target): + """Return the DUT's current time, to compare against its own timestamps""" + data = target.get_data("/ietf-system:system-state/clock/current-datetime") + return datetime.fromisoformat(data["system-state"]["clock"]["current-datetime"]) + + def print_error_message(iface, param, exp_val, act_val): return f"'{param}' failure for interface '{iface}'. Expected '{exp_val}', Actual: '{act_val}'" @@ -89,14 +98,24 @@ def configure_interface(target, iface_name, iface_type=None, enabled=True, ip_ad with test.step("Verify the interface is disabled"): assert_param(target2, iface_under_test, "admin-status", "down") assert_param(target2, iface_under_test, "oper-status", "down") + disabled_at = iface.get_last_change(target2, iface_under_test) with test.step("Enable the interface and assign an IP address"): configure_interface(target2, iface_under_test, enabled=True, ip_address=target_address) - + with test.step("Verify the interface is enabled"): assert_param(target2, iface_under_test, "admin-status", "up") assert_param(target2, iface_under_test, "oper-status", "up") + with test.step("Verify last-change reflects the transition to up"): + enabled_at = until(lambda: iface.get_last_change(target2, iface_under_test)) + assert enabled_at <= dut_now(target2), \ + f"last-change {enabled_at} is in the future" + # Disabling a link that was already down is no transition, so no stamp + if disabled_at: + assert enabled_at >= disabled_at, \ + f"last-change {enabled_at} predates the disable at {disabled_at}" + with infamy.IsolatedMacVlan(host_send_iface) as send_ns: with test.step("Verify it is possible to ping the interface"): send_ns.addip(host_address) diff --git a/test/infamy/iface.py b/test/infamy/iface.py index 6ee368cd85..b5b58d25b1 100644 --- a/test/infamy/iface.py +++ b/test/infamy/iface.py @@ -1,6 +1,7 @@ """ Fetch interface status from remote device. """ +from datetime import datetime def get_xpath(iface, path=None): @@ -103,6 +104,12 @@ def get_oper_status(target, iface): return get_param(target, iface, "oper-status") +def get_last_change(target, iface): + """Get when the interface entered its operational status, None if unknown""" + stamp = get_param(target, iface, "last-change") + return datetime.fromisoformat(stamp) if stamp else None + + def is_oper_up(target, iface): """Check if interface operational status is 'up'""" return get_oper_status(target, iface) == "up" From 2fd8cec7c1c851b8e0fd156ef6280631205247e8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 14 Sep 2026 18:59:30 +0200 Subject: [PATCH 4/4] webui: show last change and interface layering The interface detail page lists a fixed set of status fields, so the new ietf-interfaces leaves did not show up. Add last change with its age, and the lower and higher layer interfaces linked to their own pages. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/interfaces.go | 26 +++++++++++++++++++ .../internal/handlers/interfaces_test.go | 22 ++++++++++++++++ src/webui/templates/pages/iface-detail.html | 5 ++++ 3 files changed, 53 insertions(+) create mode 100644 src/webui/internal/handlers/interfaces_test.go diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index fc1a3db494..c1dc9ea4e3 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -3,6 +3,7 @@ package handlers import ( + "time" "fmt" "html/template" "log" @@ -32,6 +33,9 @@ type ifaceJSON struct { Type string `json:"type"` Enabled *bool `json:"enabled"` OperStatus string `json:"oper-status"` + LastChange string `json:"last-change"` + HigherLayerIf []string `json:"higher-layer-if"` + LowerLayerIf []string `json:"lower-layer-if"` PhysAddress string `json:"phys-address"` CustomPhysAddress *customPhysAddress `json:"infix-interfaces:custom-phys-address"` IfIndex int `json:"if-index"` @@ -585,6 +589,9 @@ type ifaceDetailData struct { Type string Status string StatusUp bool + LastChange string // local time with age, "" if unknown + HigherLayer []string // interfaces stacked on top of this one + LowerLayer []string // interfaces this one is stacked on PhysAddr string IfIndex int MTU int @@ -696,6 +703,9 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { StatusUp: iface.OperStatus == "up", PhysAddr: iface.PhysAddress, IfIndex: iface.IfIndex, + LastChange: formatLastChange(iface.LastChange), + HigherLayer: iface.HigherLayerIf, + LowerLayer: iface.LowerLayerIf, } if iface.IPv4 != nil { @@ -1003,6 +1013,19 @@ func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry { return e } +// formatLastChange renders a YANG date-and-time as local time with its age. +func formatLastChange(s string) string { + if s == "" { + return "" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return fmt.Sprintf("%s (%s ago)", t.Local().Format("2006-01-02 15:04:05"), + formatRelDuration(time.Since(t))) +} + func formatDuration(secs int64) string { if secs < 60 { return fmt.Sprintf("%ds", secs) @@ -1069,6 +1092,9 @@ func (h *InterfacesHandler) fieldDescriptions() map[string]string { ethPath := ifPath + "/ieee802-ethernet-interface:ethernet" return map[string]string{ "mtu": schema.DescriptionOf(mgr, ifPath+"/ietf-ip:ipv4/mtu"), + "last-change": schema.DescriptionOf(mgr, ifPath+"/last-change"), + "higher-layer-if": schema.DescriptionOf(mgr, ifPath+"/higher-layer-if"), + "lower-layer-if": schema.DescriptionOf(mgr, ifPath+"/lower-layer-if"), "speed": schema.DescriptionOf(mgr, ifPath+"/speed"), "duplex": schema.DescriptionOf(mgr, ethPath+"/duplex"), "autoneg": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/enable"), diff --git a/src/webui/internal/handlers/interfaces_test.go b/src/webui/internal/handlers/interfaces_test.go new file mode 100644 index 0000000000..c06377f655 --- /dev/null +++ b/src/webui/internal/handlers/interfaces_test.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "strings" + "testing" + "time" +) + +func TestFormatLastChange(t *testing.T) { + if got := formatLastChange(""); got != "" { + t.Errorf("empty: got %q", got) + } + if got := formatLastChange("garbage"); got != "garbage" { + t.Errorf("unparsable: got %q", got) + } + + stamp := time.Now().Add(-90 * time.Second).UTC().Format("2006-01-02T15:04:05+00:00") + got := formatLastChange(stamp) + if !strings.HasSuffix(got, "(1m ago)") { + t.Errorf("age: got %q", got) + } +} diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html index f9fe523338..fb3a3814b4 100644 --- a/src/webui/templates/pages/iface-detail.html +++ b/src/webui/templates/pages/iface-detail.html @@ -26,6 +26,9 @@

{{.Name}}

Status {{.Status}} + {{if .LastChange}}Last change{{template "field-info" (index .Desc "last-change")}}{{.LastChange}}{{end}} + {{if .LowerLayer}}Lower layer{{template "field-info" (index .Desc "lower-layer-if")}}{{template "iface-links" .LowerLayer}}{{end}} + {{if .HigherLayer}}Higher layer{{template "field-info" (index .Desc "higher-layer-if")}}{{template "iface-links" .HigherLayer}}{{end}} {{if .PhysAddr}}MAC Address{{.PhysAddr}}{{end}} {{if .MTU}}MTU{{template "field-info" (index .Desc "mtu")}}{{.MTU}}{{end}} {{if .Speed}}Speed{{template "field-info" (index .Desc "speed")}}{{.Speed}}{{end}} @@ -180,3 +183,5 @@

Addresses

{{end}} {{end}} + +{{define "iface-links"}}{{range $i, $name := .}}{{if $i}}, {{end}}{{$name}}{{end}}{{end}}